Compare commits
27
Commits
783d25b1c4
...
15bc299987
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15bc299987 | ||
|
|
87bdb0c6ed | ||
|
|
aa96bbedbc | ||
|
|
4df277c81f | ||
|
|
12646b6ca0 | ||
|
|
1e674d70c2 | ||
|
|
5ee77698db | ||
|
|
d1f5661881 | ||
|
|
532d078720 | ||
|
|
27e5df106f | ||
|
|
33d98868c3 | ||
|
|
fb13e53cb5 | ||
|
|
60a5495ccd | ||
|
|
f1dc90621c | ||
|
|
eecb116709 | ||
|
|
af06eecfd0 | ||
|
|
9bd08a3a5b | ||
|
|
3d66247e11 | ||
|
|
4390554477 | ||
|
|
74457db4eb | ||
|
|
5d61da481b | ||
|
|
42d109cae3 | ||
|
|
9b48b1ff5d | ||
|
|
eac4a0c071 | ||
|
|
e62c7cf4f5 | ||
|
|
0245980ea5 | ||
|
|
68b1aa64e9 |
@@ -40,7 +40,7 @@ use tracing_subscriber::EnvFilter;
|
|||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine, EngineRunExit, RunInterruptionReason,
|
Engine, EngineRunExit, RunInterruptionReason,
|
||||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||||
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ToolResultPrinterPolicy {
|
impl Interceptor for ToolResultPrinterPolicy {
|
||||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &ToolResultInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
let name = self
|
let name = self
|
||||||
.call_names
|
.call_names
|
||||||
.lock()
|
.lock()
|
||||||
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
|||||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
PostToolAction::Continue
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+336
-106
@@ -15,8 +15,12 @@ use crate::{
|
|||||||
},
|
},
|
||||||
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
|
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
|
||||||
interceptor::{
|
interceptor::{
|
||||||
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction,
|
AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
|
||||||
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction,
|
InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
|
||||||
|
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
|
||||||
|
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
|
||||||
|
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
|
||||||
|
ToolCallInfo, ToolResultInfo, TurnEndAction,
|
||||||
},
|
},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
|
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
|
||||||
@@ -58,6 +62,9 @@ pub enum EngineError {
|
|||||||
/// A durable-history observer rejected an item before it entered history.
|
/// A durable-history observer rejected an item before it entered history.
|
||||||
#[error("History append failed: {0}")]
|
#[error("History append failed: {0}")]
|
||||||
HistoryAppend(String),
|
HistoryAppend(String),
|
||||||
|
/// A trusted host interceptor callback failed.
|
||||||
|
#[error(transparent)]
|
||||||
|
Interceptor(#[from] InterceptorFailure),
|
||||||
/// Tool terminalization lost its execution-attempt compare-and-set fence.
|
/// Tool terminalization lost its execution-attempt compare-and-set fence.
|
||||||
#[error("Tool execution attempt fence failed: {0}")]
|
#[error("Tool execution attempt fence failed: {0}")]
|
||||||
ToolAttemptFence(String),
|
ToolAttemptFence(String),
|
||||||
@@ -181,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
|
|||||||
/// Result of [`Engine::run`] or [`Engine::resume`].
|
/// Result of [`Engine::run`] or [`Engine::resume`].
|
||||||
///
|
///
|
||||||
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
||||||
pub struct EngineRunOutput<C: LlmClient, A = ()> {
|
pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
|
||||||
/// The Engine, now in Locked state.
|
/// The Engine, now in Locked state.
|
||||||
pub engine: Engine<C, Locked, A>,
|
pub engine: Engine<C, Locked, A>,
|
||||||
/// Outcome of the turn.
|
/// Outcome of the turn.
|
||||||
@@ -305,7 +312,7 @@ enum StreamCompletion {
|
|||||||
Interrupted { reason: String },
|
Interrupted { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||||
/// LLM client
|
/// LLM client
|
||||||
client: C,
|
client: C,
|
||||||
/// Retry policy for opening an LLM response stream.
|
/// Retry policy for opening an LLM response stream.
|
||||||
@@ -322,7 +329,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
|||||||
/// Tool server handle
|
/// Tool server handle
|
||||||
tool_server: ToolServerHandle,
|
tool_server: ToolServerHandle,
|
||||||
/// Interceptor for control-flow decisions
|
/// Interceptor for control-flow decisions
|
||||||
interceptor: Box<dyn Interceptor>,
|
interceptor: Box<dyn Interceptor<A>>,
|
||||||
/// System prompt
|
/// System prompt
|
||||||
system_prompt: Option<String>,
|
system_prompt: Option<String>,
|
||||||
/// History length at lock time (only meaningful in Locked state)
|
/// History length at lock time (only meaningful in Locked state)
|
||||||
@@ -341,6 +348,11 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
|||||||
/// `max_turns` is enforced against this run-scoped count rather than the
|
/// `max_turns` is enforced against this run-scoped count rather than the
|
||||||
/// cumulative `turn_count` above.
|
/// cumulative `turn_count` above.
|
||||||
active_run_turn_count: Option<usize>,
|
active_run_turn_count: Option<usize>,
|
||||||
|
/// Identity retained across pause/yield and resume.
|
||||||
|
active_run_id: Option<InterceptorRunId>,
|
||||||
|
next_run_id: u64,
|
||||||
|
interceptor_invocation_count: usize,
|
||||||
|
last_run_exit_observer_failure: Option<InterceptorFailure>,
|
||||||
/// LlmCall count (per-Engine running counter, monotonic). Unlike
|
/// LlmCall count (per-Engine running counter, monotonic). Unlike
|
||||||
/// `turn_count` this never collapses retries.
|
/// `turn_count` this never collapses retries.
|
||||||
llm_call_count: usize,
|
llm_call_count: usize,
|
||||||
@@ -421,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
|||||||
_state: PhantomData<(S, A)>,
|
_state: PhantomData<(S, A)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||||
fn start_logical_run(&mut self) {
|
fn start_logical_run(&mut self) {
|
||||||
self.active_run_turn_count = Some(0);
|
self.active_run_turn_count = Some(0);
|
||||||
|
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||||
|
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||||
|
self.interceptor_invocation_count = 0;
|
||||||
|
self.last_run_exit_observer_failure = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_logical_run(&mut self) {
|
fn ensure_logical_run(&mut self) {
|
||||||
self.active_run_turn_count.get_or_insert(0);
|
self.active_run_turn_count.get_or_insert(0);
|
||||||
|
if self.active_run_id.is_none() {
|
||||||
|
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||||
|
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||||
|
self.interceptor_invocation_count = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
|
fn interceptor_invocation(
|
||||||
if !matches!(
|
&mut self,
|
||||||
result,
|
phase: InterceptorPhase,
|
||||||
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested)
|
turn_id: Option<usize>,
|
||||||
) {
|
call_id: Option<InterceptorCallId>,
|
||||||
|
tool_call: usize,
|
||||||
|
) -> InterceptorInvocation {
|
||||||
|
let invocation = self.interceptor_invocation_count;
|
||||||
|
self.interceptor_invocation_count = self.interceptor_invocation_count.saturating_add(1);
|
||||||
|
InterceptorInvocation {
|
||||||
|
run_id: self
|
||||||
|
.active_run_id
|
||||||
|
.expect("logical run identity must exist before interception"),
|
||||||
|
turn_id: turn_id.map(|value| InterceptorTurnId(value as u64)),
|
||||||
|
call_id,
|
||||||
|
phase,
|
||||||
|
counters: InterceptorCounters {
|
||||||
|
invocation: InterceptorCounter::from_usize(invocation),
|
||||||
|
engine_turn: InterceptorCounter::from_usize(self.turn_count),
|
||||||
|
run_turn: InterceptorCounter::from_usize(
|
||||||
|
self.active_run_turn_count.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
llm_call: InterceptorCounter::from_usize(self.llm_call_count),
|
||||||
|
tool_batch: InterceptorCounter::from_usize(self.tool_execution_batch_count),
|
||||||
|
tool_call: InterceptorCounter::from_usize(tool_call),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_logical_run(&mut self, exit: &EngineRunExit) {
|
||||||
|
if !matches!(exit, EngineRunExit::Paused | EngineRunExit::Yielded) {
|
||||||
self.active_run_turn_count = None;
|
self.active_run_turn_count = None;
|
||||||
|
self.active_run_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +789,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
/// The interceptor governs approval, skip, pause, and abort decisions
|
/// The interceptor governs approval, skip, pause, and abort decisions
|
||||||
/// at key points in the execution loop. If not set, the default
|
/// at key points in the execution loop. If not set, the default
|
||||||
/// interceptor is used (all Continue / Finish).
|
/// interceptor is used (all Continue / Finish).
|
||||||
pub fn set_interceptor(&mut self, interceptor: impl Interceptor + 'static) {
|
pub fn set_interceptor(&mut self, interceptor: impl Interceptor<A> + 'static) {
|
||||||
self.interceptor = Box::new(interceptor);
|
self.interceptor = Box::new(interceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,6 +890,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
///
|
///
|
||||||
/// `Some` is retained only while Pause or Yield permits a later
|
/// `Some` is retained only while Pause or Yield permits a later
|
||||||
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
|
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
|
||||||
|
pub fn last_run_exit_observer_failure(&self) -> Option<&InterceptorFailure> {
|
||||||
|
self.last_run_exit_observer_failure.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_run_turn_count(&self) -> Option<usize> {
|
pub fn active_run_turn_count(&self) -> Option<usize> {
|
||||||
self.active_run_turn_count
|
self.active_run_turn_count
|
||||||
}
|
}
|
||||||
@@ -853,6 +905,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
/// [`resume`](Self::resume) starts a fresh budget.
|
/// [`resume`](Self::resume) starts a fresh budget.
|
||||||
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
|
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
|
||||||
self.active_run_turn_count = turn_count;
|
self.active_run_turn_count = turn_count;
|
||||||
|
if turn_count.is_none() {
|
||||||
|
self.active_run_id = None;
|
||||||
|
} else if self.active_run_id.is_none() {
|
||||||
|
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||||
|
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||||
|
self.interceptor_invocation_count = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current LlmCall count (per-Engine running counter, never
|
/// Get the current LlmCall count (per-Engine running counter, never
|
||||||
@@ -1078,24 +1137,28 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hooks: on_prompt_submit
|
async fn finalize_run_exit(
|
||||||
///
|
|
||||||
async fn finalize_interruption<T>(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
result: Result<T, EngineError>,
|
history: &History<A>,
|
||||||
) -> Result<T, EngineError> {
|
result: Result<EngineResult, EngineError>,
|
||||||
match result {
|
) -> EngineRunExit {
|
||||||
Ok(value) => Ok(value),
|
let exit = EngineRunExit::from(result);
|
||||||
Err(err) => {
|
let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
|
||||||
let reason = match &err {
|
self.last_run_exit_observer_failure = None;
|
||||||
EngineError::Aborted(reason) => reason.clone(),
|
if let Err(error) = self
|
||||||
EngineError::Cancelled => "Cancelled".to_string(),
|
.interceptor
|
||||||
_ => err.to_string(),
|
.on_run_exit(RunExitContext {
|
||||||
};
|
invocation,
|
||||||
self.interceptor.on_abort(&reason).await;
|
exit: &exit,
|
||||||
Err(err)
|
history: history.entries(),
|
||||||
}
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
self.last_run_exit_observer_failure =
|
||||||
|
Some(InterceptorFailure::new(InterceptorPhase::RunExit, error));
|
||||||
}
|
}
|
||||||
|
self.finish_logical_run(&exit);
|
||||||
|
exit
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check for pending tool calls (for resuming from Pause)
|
/// Check for pending tool calls (for resuming from Pause)
|
||||||
@@ -1166,21 +1229,60 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||||
let mut approved_calls = Vec::new();
|
let mut approved_calls = Vec::new();
|
||||||
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
||||||
|
let expected_tool_use_id = tool_call.id.clone();
|
||||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||||
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
||||||
|
let invocation = self.interceptor_invocation(
|
||||||
|
InterceptorPhase::PreToolCall,
|
||||||
|
Some(self.turn_count.saturating_sub(1)),
|
||||||
|
Some(InterceptorCallId::Tool(expected_tool_use_id.clone())),
|
||||||
|
call_index,
|
||||||
|
);
|
||||||
let mut info = ToolCallInfo {
|
let mut info = ToolCallInfo {
|
||||||
|
invocation,
|
||||||
|
history: history.entries(),
|
||||||
call: tool_call.clone(),
|
call: tool_call.clone(),
|
||||||
meta,
|
meta,
|
||||||
tool,
|
tool,
|
||||||
context,
|
context,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.interceptor.pre_tool_call(&mut info).await {
|
let pre_tool_action =
|
||||||
|
self.interceptor
|
||||||
|
.pre_tool_call(&mut info)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PreToolCall,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if info.call.id != expected_tool_use_id {
|
||||||
|
return Err(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PreToolCall,
|
||||||
|
InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::ContractViolation,
|
||||||
|
"pre-tool interceptor changed immutable tool call identity",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
match pre_tool_action {
|
||||||
PreToolAction::Continue => {}
|
PreToolAction::Continue => {}
|
||||||
PreToolAction::Skip => {
|
PreToolAction::Skip => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
PreToolAction::SyntheticResult(result) => {
|
PreToolAction::SyntheticResult(result) => {
|
||||||
|
if result.tool_use_id != expected_tool_use_id {
|
||||||
|
return Err(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PreToolCall,
|
||||||
|
InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::ContractViolation,
|
||||||
|
"synthetic tool result changed immutable tool call identity",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
let tool_call = info.call;
|
let tool_call = info.call;
|
||||||
let mut context = info.context;
|
let mut context = info.context;
|
||||||
context.call_id = tool_call.id.clone();
|
context.call_id = tool_call.id.clone();
|
||||||
@@ -1287,20 +1389,31 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
let mut terminal_call_ids = HashSet::new();
|
let mut terminal_call_ids = HashSet::new();
|
||||||
let mut pause_requested = false;
|
let mut pause_requested = false;
|
||||||
let mut pause_deadline = None;
|
let mut pause_deadline = None;
|
||||||
|
let mut batch_error = None;
|
||||||
|
let mut locally_enqueued_cancel = false;
|
||||||
for result in synthetic_results {
|
for result in synthetic_results {
|
||||||
self.finalize_and_commit_tool_result(
|
if let Err(error) = self
|
||||||
history,
|
.finalize_and_commit_tool_result(
|
||||||
annotate,
|
history,
|
||||||
result,
|
annotate,
|
||||||
None,
|
result,
|
||||||
&call_info_map,
|
None,
|
||||||
&mut attempt_fence,
|
&call_info_map,
|
||||||
&mut terminal_call_ids,
|
&mut attempt_fence,
|
||||||
)
|
&mut terminal_call_ids,
|
||||||
.await?;
|
)
|
||||||
|
.await
|
||||||
|
&& batch_error.is_none()
|
||||||
|
{
|
||||||
|
batch_error = Some(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut futures = futures;
|
let mut futures = futures;
|
||||||
|
if batch_error.is_some() && !futures.is_empty() {
|
||||||
|
let _ = self.cancel_tx.try_send(());
|
||||||
|
locally_enqueued_cancel = true;
|
||||||
|
}
|
||||||
while !futures.is_empty() {
|
while !futures.is_empty() {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// If cancellation and a completed result are both ready, drain
|
// If cancellation and a completed result are both ready, drain
|
||||||
@@ -1310,7 +1423,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
result = futures.next() => {
|
result = futures.next() => {
|
||||||
let (attempt_id, result) =
|
let (attempt_id, result) =
|
||||||
result.expect("non-empty FuturesUnordered returns a result");
|
result.expect("non-empty FuturesUnordered returns a result");
|
||||||
self.finalize_and_commit_tool_result(
|
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||||
history,
|
history,
|
||||||
annotate,
|
annotate,
|
||||||
result,
|
result,
|
||||||
@@ -1318,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
&call_info_map,
|
&call_info_map,
|
||||||
&mut attempt_fence,
|
&mut attempt_fence,
|
||||||
&mut terminal_call_ids,
|
&mut terminal_call_ids,
|
||||||
).await?;
|
).await {
|
||||||
|
if batch_error.is_none() {
|
||||||
|
batch_error = Some(error);
|
||||||
|
}
|
||||||
|
if !futures.is_empty() {
|
||||||
|
let _ = self.cancel_tx.try_send(());
|
||||||
|
locally_enqueued_cancel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pause = self.pause_rx.recv(), if !pause_requested => {
|
pause = self.pause_rx.recv(), if !pause_requested => {
|
||||||
if pause.is_some() {
|
if pause.is_some() {
|
||||||
@@ -1335,6 +1456,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||||
pause_deadline = None;
|
pause_deadline = None;
|
||||||
let _ = self.cancel_tx.try_send(());
|
let _ = self.cancel_tx.try_send(());
|
||||||
|
locally_enqueued_cancel = true;
|
||||||
}
|
}
|
||||||
cancel = self.cancel_rx.recv() => {
|
cancel = self.cancel_rx.recv() => {
|
||||||
if cancel.is_some() {
|
if cancel.is_some() {
|
||||||
@@ -1380,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
result = futures.next() => {
|
result = futures.next() => {
|
||||||
let (attempt_id, result) =
|
let (attempt_id, result) =
|
||||||
result.expect("non-empty FuturesUnordered returns a result");
|
result.expect("non-empty FuturesUnordered returns a result");
|
||||||
self.finalize_and_commit_tool_result(
|
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||||
history,
|
history,
|
||||||
annotate,
|
annotate,
|
||||||
result,
|
result,
|
||||||
@@ -1388,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
&call_info_map,
|
&call_info_map,
|
||||||
&mut attempt_fence,
|
&mut attempt_fence,
|
||||||
&mut terminal_call_ids,
|
&mut terminal_call_ids,
|
||||||
).await?;
|
).await
|
||||||
|
&& batch_error.is_none()
|
||||||
|
{
|
||||||
|
batch_error = Some(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep_until(deadline) => break,
|
_ = tokio::time::sleep_until(deadline) => break,
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1528,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
if let Some(handle) = execution_handles.get(call_id) {
|
if let Some(handle) = execution_handles.get(call_id) {
|
||||||
handle.force_close();
|
handle.force_close();
|
||||||
}
|
}
|
||||||
self.finalize_and_commit_tool_result(
|
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||||
history,
|
history,
|
||||||
annotate,
|
annotate,
|
||||||
ToolResult::outcome_unknown(call_id),
|
ToolResult::outcome_unknown(call_id),
|
||||||
@@ -1410,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
&call_info_map,
|
&call_info_map,
|
||||||
&mut attempt_fence,
|
&mut attempt_fence,
|
||||||
&mut terminal_call_ids,
|
&mut terminal_call_ids,
|
||||||
).await?;
|
).await
|
||||||
|
&& batch_error.is_none()
|
||||||
|
{
|
||||||
|
batch_error = Some(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.timeline.abort_current_block();
|
self.timeline.abort_current_block();
|
||||||
|
if let Some(error) = batch_error.take() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
if pause_requested {
|
if pause_requested {
|
||||||
return Ok(ToolExecutionResult::Paused);
|
return Ok(ToolExecutionResult::Paused);
|
||||||
}
|
}
|
||||||
@@ -1423,6 +1556,16 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A result-biased ready sibling can empty the batch before the local
|
||||||
|
// cancel signal is selected. Never let that current-batch signal leak
|
||||||
|
// into the next run or resume call.
|
||||||
|
if locally_enqueued_cancel {
|
||||||
|
let _ = self.cancel_rx.try_recv();
|
||||||
|
}
|
||||||
|
if let Some(error) = batch_error {
|
||||||
|
self.timeline.abort_current_block();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
Ok(if pause_requested {
|
Ok(if pause_requested {
|
||||||
ToolExecutionResult::Paused
|
ToolExecutionResult::Paused
|
||||||
} else {
|
} else {
|
||||||
@@ -1466,31 +1609,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let call_info = call_info_map.get(&tool_result.tool_use_id);
|
let call_info = call_info_map.get(&tool_result.tool_use_id);
|
||||||
let mut abort_reason = None;
|
|
||||||
if let Some((tool_call, meta, tool, context)) = call_info {
|
|
||||||
let mut info = ToolResultInfo {
|
|
||||||
call: tool_call.clone(),
|
|
||||||
result: tool_result,
|
|
||||||
meta: meta.clone(),
|
|
||||||
tool: tool.clone(),
|
|
||||||
context: context.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.interceptor.post_tool_call(&mut info).await {
|
|
||||||
PostToolAction::Continue => {}
|
|
||||||
PostToolAction::Abort(reason) => {
|
|
||||||
abort_reason = Some(reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tool_result = info.result;
|
|
||||||
}
|
|
||||||
if tool_result.is_error && tool_result.disposition.is_success() {
|
if tool_result.is_error && tool_result.disposition.is_success() {
|
||||||
tool_result.disposition = ToolResultDisposition::Error;
|
tool_result.disposition = ToolResultDisposition::Error;
|
||||||
}
|
}
|
||||||
tool_result.is_error = !tool_result.disposition.is_success();
|
tool_result.is_error = !tool_result.disposition.is_success();
|
||||||
|
|
||||||
// Cap content only after post_tool_call so interceptors still observe
|
// Bound the terminal payload before committing it so the post-tool
|
||||||
// the full payload and any content they inject is bounded too.
|
// interceptor observes exactly the model-visible durable result.
|
||||||
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
|
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
|
||||||
self.tool_output_limits.as_ref(),
|
self.tool_output_limits.as_ref(),
|
||||||
call_info,
|
call_info,
|
||||||
@@ -1543,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
"Tool execution terminalized"
|
"Tool execution terminalized"
|
||||||
);
|
);
|
||||||
self.emit_tool_result(&tool_result);
|
self.emit_tool_result(&tool_result);
|
||||||
if let Some(reason) = abort_reason {
|
|
||||||
return Err(EngineError::Aborted(reason));
|
if let Some((tool_call, meta, tool, context)) = call_info {
|
||||||
|
let invocation = self.interceptor_invocation(
|
||||||
|
InterceptorPhase::PostToolCall,
|
||||||
|
Some(self.turn_count.saturating_sub(1)),
|
||||||
|
Some(InterceptorCallId::Tool(tool_call.id.clone())),
|
||||||
|
context.call_index,
|
||||||
|
);
|
||||||
|
let info = ToolResultInfo {
|
||||||
|
invocation,
|
||||||
|
history: history.entries(),
|
||||||
|
call: tool_call.clone(),
|
||||||
|
result: tool_result,
|
||||||
|
meta: meta.clone(),
|
||||||
|
tool: tool.clone(),
|
||||||
|
context: context.clone(),
|
||||||
|
};
|
||||||
|
let post_tool_action =
|
||||||
|
self.interceptor
|
||||||
|
.post_tool_call(&info)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PostToolCall,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||||
|
return Err(EngineError::Aborted(reason));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1608,11 +1762,25 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
// These are committed *before* the per-request clone so they
|
// These are committed *before* the per-request clone so they
|
||||||
// participate in the LLM request below and get persisted by
|
// participate in the LLM request below and get persisted by
|
||||||
// the caller that owns durable history.
|
// the caller that owns durable history.
|
||||||
|
let pending_invocation = self.interceptor_invocation(
|
||||||
|
InterceptorPhase::PendingHistoryAppends,
|
||||||
|
Some(current_turn),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
);
|
||||||
let pending = self
|
let pending = self
|
||||||
.interceptor
|
.interceptor
|
||||||
.pending_history_appends()
|
.pending_history_appends(PendingHistoryAppendsContext {
|
||||||
|
invocation: pending_invocation,
|
||||||
|
history: history.entries(),
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(EngineError::HistoryAppend)?;
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PendingHistoryAppends,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
self.append_history_items(history, pending, annotate)?;
|
self.append_history_items(history, pending, annotate)?;
|
||||||
}
|
}
|
||||||
@@ -1679,7 +1847,27 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Interceptor: pre_llm_request
|
// Interceptor: pre_llm_request
|
||||||
match self.interceptor.pre_llm_request(&mut request_context).await {
|
let request_invocation = self.interceptor_invocation(
|
||||||
|
InterceptorPhase::PreLlmRequest,
|
||||||
|
Some(current_turn),
|
||||||
|
Some(InterceptorCallId::Llm(self.llm_call_count as u64)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let pre_request_action = self
|
||||||
|
.interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: request_invocation,
|
||||||
|
items: &mut request_context,
|
||||||
|
history: history.entries(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PreLlmRequest,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match pre_request_action {
|
||||||
PreRequestAction::Cancel(reason) => {
|
PreRequestAction::Cancel(reason) => {
|
||||||
info!(reason = %reason, "Aborted by interceptor");
|
info!(reason = %reason, "Aborted by interceptor");
|
||||||
for cb in &self.turn_end_cbs {
|
for cb in &self.turn_end_cbs {
|
||||||
@@ -1791,21 +1979,45 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
let tool_calls = self.tool_call_collector.take_collected();
|
let tool_calls = self.tool_call_collector.take_collected();
|
||||||
let assistant_items =
|
let assistant_items =
|
||||||
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
||||||
|
let assistant_start = history.len();
|
||||||
self.append_history_items(history, assistant_items, annotate)?;
|
self.append_history_items(history, assistant_items, annotate)?;
|
||||||
|
|
||||||
if tool_calls.is_empty() {
|
let assistant_invocation = self.interceptor_invocation(
|
||||||
let turn_end_context = history.items_cloned();
|
InterceptorPhase::AssistantTurnEnd,
|
||||||
match self.interceptor.on_turn_end(&turn_end_context).await {
|
Some(current_turn),
|
||||||
TurnEndAction::Finish => {
|
Some(InterceptorCallId::Llm(
|
||||||
return Ok(EngineResult::Finished);
|
self.llm_call_count.saturating_sub(1) as u64,
|
||||||
}
|
)),
|
||||||
TurnEndAction::ContinueWithMessages(additional) => {
|
0,
|
||||||
self.append_history_items(history, additional, annotate)?;
|
);
|
||||||
|
let assistant_turn_action = self
|
||||||
|
.interceptor
|
||||||
|
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||||
|
invocation: assistant_invocation,
|
||||||
|
assistant_entries: &history.entries()[assistant_start..],
|
||||||
|
history: history.entries(),
|
||||||
|
tool_calls: &tool_calls,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::AssistantTurnEnd,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match assistant_turn_action {
|
||||||
|
TurnEndAction::Finish if tool_calls.is_empty() => {
|
||||||
|
return Ok(EngineResult::Finished);
|
||||||
|
}
|
||||||
|
TurnEndAction::Finish => {}
|
||||||
|
TurnEndAction::ContinueWithMessages(additional) => {
|
||||||
|
self.append_history_items(history, additional, annotate)?;
|
||||||
|
if tool_calls.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
TurnEndAction::Pause => {
|
}
|
||||||
return Ok(EngineResult::Paused);
|
TurnEndAction::Pause => {
|
||||||
}
|
return Ok(EngineResult::Paused);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2098,7 +2310,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
||||||
/// Create a new annotated Engine (in Mutable state).
|
/// Create a new annotated Engine (in Mutable state).
|
||||||
pub fn new_annotated(client: C) -> Self {
|
pub fn new_annotated(client: C) -> Self {
|
||||||
let text_block_collector = TextBlockCollector::new();
|
let text_block_collector = TextBlockCollector::new();
|
||||||
@@ -2126,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
locked_prefix_len: 0,
|
locked_prefix_len: 0,
|
||||||
turn_count: 0,
|
turn_count: 0,
|
||||||
active_run_turn_count: None,
|
active_run_turn_count: None,
|
||||||
|
active_run_id: None,
|
||||||
|
next_run_id: 1,
|
||||||
|
interceptor_invocation_count: 0,
|
||||||
|
last_run_exit_observer_failure: None,
|
||||||
llm_call_count: 0,
|
llm_call_count: 0,
|
||||||
tool_execution_batch_count: 0,
|
tool_execution_batch_count: 0,
|
||||||
max_turns: None,
|
max_turns: None,
|
||||||
@@ -2401,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
|||||||
locked_prefix_len,
|
locked_prefix_len,
|
||||||
turn_count: self.turn_count,
|
turn_count: self.turn_count,
|
||||||
active_run_turn_count: self.active_run_turn_count,
|
active_run_turn_count: self.active_run_turn_count,
|
||||||
|
active_run_id: self.active_run_id,
|
||||||
|
next_run_id: self.next_run_id,
|
||||||
|
interceptor_invocation_count: self.interceptor_invocation_count,
|
||||||
|
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
|
||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
@@ -2477,7 +2697,7 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, A> Engine<C, Locked, A> {
|
impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
|
||||||
/// Execute a turn
|
/// Execute a turn
|
||||||
///
|
///
|
||||||
/// Adds a new user message to history and sends a request to the LLM.
|
/// Adds a new user message to history and sends a request to the LLM.
|
||||||
@@ -2488,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
user_input: impl Into<String>,
|
user_input: impl Into<String>,
|
||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> EngineRunExit {
|
) -> EngineRunExit {
|
||||||
self.run_result_with_annotation(history, user_input.into(), annotate)
|
let result = self
|
||||||
.await
|
.run_result_with_annotation(history, user_input.into(), annotate)
|
||||||
.into()
|
.await;
|
||||||
|
self.finalize_run_exit(history, result).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_result_with_annotation(
|
async fn run_result_with_annotation(
|
||||||
@@ -2501,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
) -> Result<EngineResult, EngineError> {
|
) -> Result<EngineResult, EngineError> {
|
||||||
// Supplying new user input abandons any paused/yielded logical run.
|
// Supplying new user input abandons any paused/yielded logical run.
|
||||||
self.active_run_turn_count = None;
|
self.active_run_turn_count = None;
|
||||||
|
self.active_run_id = None;
|
||||||
|
self.start_logical_run();
|
||||||
let mut user_item = Item::user_message(user_input);
|
let mut user_item = Item::user_message(user_input);
|
||||||
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
|
let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
|
||||||
PromptAction::Cancel(reason) => {
|
let prompt_action = self
|
||||||
return self
|
.interceptor
|
||||||
.finalize_interruption(Err(EngineError::Aborted(reason)))
|
.on_prompt_submit(PromptSubmitContext {
|
||||||
.await;
|
invocation,
|
||||||
}
|
item: &mut user_item,
|
||||||
|
history: history.entries(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
EngineError::from(InterceptorFailure::new(
|
||||||
|
InterceptorPhase::PromptSubmit,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let extras = match prompt_action {
|
||||||
|
PromptAction::Cancel(reason) => return Err(EngineError::Aborted(reason)),
|
||||||
PromptAction::Continue => Vec::new(),
|
PromptAction::Continue => Vec::new(),
|
||||||
PromptAction::ContinueWith(items) => items,
|
PromptAction::ContinueWith(items) => items,
|
||||||
};
|
};
|
||||||
@@ -2515,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
if !extras.is_empty() {
|
if !extras.is_empty() {
|
||||||
self.append_history_items(history, extras, annotate)?;
|
self.append_history_items(history, extras, annotate)?;
|
||||||
}
|
}
|
||||||
self.start_logical_run();
|
match self.run_turn_loop(history, annotate).await {
|
||||||
let result = match self.run_turn_loop(history, annotate).await {
|
|
||||||
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||||
other => other,
|
other => other,
|
||||||
};
|
}
|
||||||
let result = self.finalize_interruption(result).await;
|
|
||||||
self.finish_logical_run(&result);
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume execution (from Paused state).
|
/// Resume execution (from Paused state).
|
||||||
@@ -2531,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
history: &mut History<A>,
|
history: &mut History<A>,
|
||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> EngineRunExit {
|
) -> EngineRunExit {
|
||||||
self.resume_result_with_annotation(history, annotate)
|
let result = self.resume_result_with_annotation(history, annotate).await;
|
||||||
.await
|
self.finalize_run_exit(history, result).await
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resume_result_with_annotation(
|
async fn resume_result_with_annotation(
|
||||||
@@ -2542,13 +2771,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> Result<EngineResult, EngineError> {
|
) -> Result<EngineResult, EngineError> {
|
||||||
self.ensure_logical_run();
|
self.ensure_logical_run();
|
||||||
let result = match self.run_turn_loop(history, annotate).await {
|
match self.run_turn_loop(history, annotate).await {
|
||||||
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||||
other => other,
|
other => other,
|
||||||
};
|
}
|
||||||
let result = self.finalize_interruption(result).await;
|
|
||||||
self.finish_logical_run(&result);
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the prefix length at lock time
|
/// Get the prefix length at lock time
|
||||||
@@ -2574,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
|||||||
locked_prefix_len: 0,
|
locked_prefix_len: 0,
|
||||||
turn_count: self.turn_count,
|
turn_count: self.turn_count,
|
||||||
active_run_turn_count: self.active_run_turn_count,
|
active_run_turn_count: self.active_run_turn_count,
|
||||||
|
active_run_id: self.active_run_id,
|
||||||
|
next_run_id: self.next_run_id,
|
||||||
|
interceptor_invocation_count: self.interceptor_invocation_count,
|
||||||
|
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
|
||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
|
|||||||
+250
-28
@@ -9,8 +9,202 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::Item;
|
use crate::Item;
|
||||||
|
use crate::engine::EngineRunExit;
|
||||||
|
use crate::history::HistoryEntry;
|
||||||
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Typed lifecycle metadata and failures
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
|
||||||
|
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
|
||||||
|
|
||||||
|
/// Stable category for the source of an interceptor failure.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InterceptorErrorCategory {
|
||||||
|
Policy,
|
||||||
|
Dependency,
|
||||||
|
ContractViolation,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for InterceptorErrorCategory {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::Policy => "policy",
|
||||||
|
Self::Dependency => "dependency",
|
||||||
|
Self::ContractViolation => "contract_violation",
|
||||||
|
Self::Internal => "internal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("{category}: {diagnostic}")]
|
||||||
|
pub struct InterceptorError {
|
||||||
|
category: InterceptorErrorCategory,
|
||||||
|
diagnostic: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterceptorError {
|
||||||
|
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
|
||||||
|
let mut diagnostic = diagnostic.into();
|
||||||
|
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
|
||||||
|
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
|
||||||
|
while !diagnostic.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
diagnostic.truncate(end);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
category,
|
||||||
|
diagnostic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category(&self) -> InterceptorErrorCategory {
|
||||||
|
self.category
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagnostic(&self) -> &str {
|
||||||
|
&self.diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lifecycle phase at which an interceptor callback executes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum InterceptorPhase {
|
||||||
|
#[default]
|
||||||
|
PromptSubmit,
|
||||||
|
PendingHistoryAppends,
|
||||||
|
PreLlmRequest,
|
||||||
|
PreToolCall,
|
||||||
|
PostToolCall,
|
||||||
|
AssistantTurnEnd,
|
||||||
|
RunExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for InterceptorPhase {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::PromptSubmit => "prompt_submit",
|
||||||
|
Self::PendingHistoryAppends => "pending_history_appends",
|
||||||
|
Self::PreLlmRequest => "pre_llm_request",
|
||||||
|
Self::PreToolCall => "pre_tool_call",
|
||||||
|
Self::PostToolCall => "post_tool_call",
|
||||||
|
Self::AssistantTurnEnd => "assistant_turn_end",
|
||||||
|
Self::RunExit => "run_exit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub struct InterceptorRunId(pub u64);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct InterceptorTurnId(pub u64);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum InterceptorCallId {
|
||||||
|
Llm(u64),
|
||||||
|
Tool(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating public counter used by interceptor contexts.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||||
|
pub struct InterceptorCounter(u32);
|
||||||
|
|
||||||
|
impl InterceptorCounter {
|
||||||
|
pub fn from_usize(value: usize) -> Self {
|
||||||
|
Self(u32::try_from(value).unwrap_or(u32::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct InterceptorCounters {
|
||||||
|
pub invocation: InterceptorCounter,
|
||||||
|
pub engine_turn: InterceptorCounter,
|
||||||
|
pub run_turn: InterceptorCounter,
|
||||||
|
pub llm_call: InterceptorCounter,
|
||||||
|
pub tool_batch: InterceptorCounter,
|
||||||
|
pub tool_call: InterceptorCounter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity, phase, and bounded counters common to every lifecycle callback.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct InterceptorInvocation {
|
||||||
|
pub run_id: InterceptorRunId,
|
||||||
|
pub turn_id: Option<InterceptorTurnId>,
|
||||||
|
pub call_id: Option<InterceptorCallId>,
|
||||||
|
pub phase: InterceptorPhase,
|
||||||
|
pub counters: InterceptorCounters,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("{phase} interceptor failed: {error}")]
|
||||||
|
pub struct InterceptorFailure {
|
||||||
|
phase: InterceptorPhase,
|
||||||
|
#[source]
|
||||||
|
error: InterceptorError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterceptorFailure {
|
||||||
|
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
|
||||||
|
Self { phase, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn phase(&self) -> InterceptorPhase {
|
||||||
|
self.phase
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> &InterceptorError {
|
||||||
|
&self.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type InterceptorResult<T> = Result<T, InterceptorError>;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Lifecycle Contexts
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub struct PromptSubmitContext<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub item: &'a mut Item,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PendingHistoryAppendsContext<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PreLlmRequestContext<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub items: &'a mut Vec<Item>,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AssistantTurnEndContext<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub assistant_entries: &'a [HistoryEntry<A>],
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
pub tool_calls: &'a [ToolCall],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RunExitContext<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub exit: &'a EngineRunExit,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Action Enums
|
// Action Enums
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -86,9 +280,9 @@ pub enum PostToolAction {
|
|||||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TurnEndAction {
|
pub enum TurnEndAction {
|
||||||
/// Turn is finished, return to caller.
|
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
||||||
Finish,
|
Finish,
|
||||||
/// Continue with additional messages injected into history.
|
/// Commit additional messages, then continue through the natural next phase.
|
||||||
ContinueWithMessages(Vec<Item>),
|
ContinueWithMessages(Vec<Item>),
|
||||||
/// Pause execution (can be resumed later).
|
/// Pause execution (can be resumed later).
|
||||||
Pause,
|
Pause,
|
||||||
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Context for pre-tool-call decisions.
|
/// Context for pre-tool-call decisions.
|
||||||
pub struct ToolCallInfo {
|
pub struct ToolCallInfo<'a, A = ()> {
|
||||||
/// Tool call information (modifiable).
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
pub call: ToolCall,
|
pub call: ToolCall,
|
||||||
/// Tool meta information.
|
/// Tool meta information.
|
||||||
pub meta: ToolMeta,
|
pub meta: ToolMeta,
|
||||||
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Context for post-tool-call decisions.
|
/// Context for post-tool-call decisions.
|
||||||
pub struct ToolResultInfo {
|
pub struct ToolResultInfo<'a, A = ()> {
|
||||||
/// Original tool call.
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
pub call: ToolCall,
|
pub call: ToolCall,
|
||||||
/// Tool execution result (modifiable).
|
/// Committed terminal tool execution result.
|
||||||
pub result: ToolResult,
|
pub result: ToolResult,
|
||||||
/// Tool meta information.
|
/// Tool meta information.
|
||||||
pub meta: ToolMeta,
|
pub meta: ToolMeta,
|
||||||
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
|
|||||||
|
|
||||||
/// Intercepts the Engine execution loop at key decision points.
|
/// Intercepts the Engine execution loop at key decision points.
|
||||||
///
|
///
|
||||||
/// All methods have default implementations that let the Engine
|
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
|
||||||
/// proceed without intervention. Callers provide richer implementations for
|
/// keeping implementation failure separate from the method's control-flow
|
||||||
/// approval flows, permission checks, etc.
|
/// action. The Engine reports a failure as a typed run interruption annotated
|
||||||
|
/// with the exact [`InterceptorPhase`] that failed.
|
||||||
|
///
|
||||||
|
/// All methods have default implementations that let the Engine proceed
|
||||||
|
/// without intervention. Callers provide richer implementations for approval
|
||||||
|
/// flows, permission checks, and other trusted host adaptation.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Interceptor: Send + Sync {
|
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||||
/// Called after receiving user input, before adding to history.
|
/// Called after receiving user input, before adding it to Engine history.
|
||||||
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
async fn on_prompt_submit(
|
||||||
PromptAction::Continue
|
&self,
|
||||||
|
_context: PromptSubmitContext<'_, A>,
|
||||||
|
) -> InterceptorResult<PromptAction> {
|
||||||
|
Ok(PromptAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Items that should be **committed to `engine.history`** just
|
/// Items that should be **committed to `engine.history`** just
|
||||||
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
|
|||||||
/// reproducible per-request transformations (pruning, content
|
/// reproducible per-request transformations (pruning, content
|
||||||
/// trimming, cache anchors) that depend only on the existing
|
/// trimming, cache anchors) that depend only on the existing
|
||||||
/// history.
|
/// history.
|
||||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
async fn pending_history_appends(
|
||||||
|
&self,
|
||||||
|
_context: PendingHistoryAppendsContext<'_, A>,
|
||||||
|
) -> InterceptorResult<Vec<Item>> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
|
|||||||
/// If an interceptor derives a human/model-visible nudge from the current
|
/// If an interceptor derives a human/model-visible nudge from the current
|
||||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||||
/// commits it to history before the request is sent.
|
/// commits it to history before the request is sent.
|
||||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(
|
||||||
PreRequestAction::Continue
|
&self,
|
||||||
|
_context: PreLlmRequestContext<'_, A>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
Ok(PreRequestAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called before each tool is executed.
|
/// Called before each tool is executed.
|
||||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
PreToolAction::Continue
|
&self,
|
||||||
|
_info: &mut ToolCallInfo<'_, A>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
Ok(PreToolAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called after each tool completes.
|
/// Called after each tool reaches one terminal result and that result is committed.
|
||||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
PostToolAction::Continue
|
&self,
|
||||||
|
_info: &ToolResultInfo<'_, A>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when a turn ends with no tool calls.
|
/// Called after every terminal assistant response is committed and before
|
||||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
/// the Engine decides whether to execute tools, continue, or finish.
|
||||||
TurnEndAction::Finish
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
_context: AssistantTurnEndContext<'_, A>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
Ok(TurnEndAction::Finish)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when execution is interrupted (abort or cancel).
|
/// Called once for the terminal outcome of each public run or resume call.
|
||||||
async fn on_abort(&self, _reason: &str) {}
|
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||||
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
|
|||||||
pub(crate) struct DefaultInterceptor;
|
pub(crate) struct DefaultInterceptor;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for DefaultInterceptor {}
|
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ pub use engine::{
|
|||||||
};
|
};
|
||||||
pub use handler::ToolUseBlockStart;
|
pub use handler::ToolUseBlockStart;
|
||||||
pub use history::{History, HistoryEntry};
|
pub use history::{History, HistoryEntry};
|
||||||
pub use interceptor::Interceptor;
|
pub use interceptor::{
|
||||||
|
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
|
||||||
|
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
|
||||||
|
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
|
||||||
|
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
|
||||||
|
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
|
||||||
|
};
|
||||||
pub use message::{ContentPart, Item, Message, Role};
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
pub use tool::{
|
pub use tool::{
|
||||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
use agen::interceptor::{
|
||||||
|
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
|
||||||
|
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
|
||||||
|
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
|
||||||
|
};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
||||||
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
fn completed_text_events(text: &str) -> Vec<Event> {
|
||||||
vec![
|
vec![
|
||||||
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
|||||||
assert_eq!(history.items_cloned().len(), 2);
|
assert_eq!(history.items_cloned().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AnnotationObservingInterceptor {
|
||||||
|
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnnotationObservingInterceptor {
|
||||||
|
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
|
||||||
|
self.observed.lock().unwrap().push((
|
||||||
|
invocation.clone(),
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.annotation.clone())
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor<String> for AnnotationObservingInterceptor {
|
||||||
|
async fn on_prompt_submit(
|
||||||
|
&self,
|
||||||
|
context: PromptSubmitContext<'_, String>,
|
||||||
|
) -> InterceptorResult<PromptAction> {
|
||||||
|
self.record(&context.invocation, context.history);
|
||||||
|
Ok(PromptAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_history_appends(
|
||||||
|
&self,
|
||||||
|
context: PendingHistoryAppendsContext<'_, String>,
|
||||||
|
) -> InterceptorResult<Vec<Item>> {
|
||||||
|
self.record(&context.invocation, context.history);
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pre_llm_request(
|
||||||
|
&self,
|
||||||
|
context: PreLlmRequestContext<'_, String>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
self.record(&context.invocation, context.history);
|
||||||
|
Ok(PreRequestAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
context: AssistantTurnEndContext<'_, String>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
assert_eq!(context.assistant_entries.len(), 1);
|
||||||
|
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
|
||||||
|
self.record(&context.invocation, context.history);
|
||||||
|
Ok(TurnEndAction::Finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
|
||||||
|
self.record(&context.invocation, context.history);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
|
||||||
|
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||||
|
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
engine.set_interceptor(AnnotationObservingInterceptor {
|
||||||
|
observed: observed.clone(),
|
||||||
|
});
|
||||||
|
let mut history = History::<String>::new();
|
||||||
|
let mut next = 0usize;
|
||||||
|
let mut annotate = |item: &Item| {
|
||||||
|
next += 1;
|
||||||
|
let kind = if item.is_assistant_message() {
|
||||||
|
"assistant"
|
||||||
|
} else {
|
||||||
|
"user"
|
||||||
|
};
|
||||||
|
Ok(format!("{next}:{kind}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = engine
|
||||||
|
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||||
|
|
||||||
|
let observed = observed.lock().unwrap();
|
||||||
|
let phases: Vec<_> = observed
|
||||||
|
.iter()
|
||||||
|
.map(|(invocation, _)| invocation.phase)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
phases,
|
||||||
|
[
|
||||||
|
InterceptorPhase::PromptSubmit,
|
||||||
|
InterceptorPhase::PendingHistoryAppends,
|
||||||
|
InterceptorPhase::PreLlmRequest,
|
||||||
|
InterceptorPhase::AssistantTurnEnd,
|
||||||
|
InterceptorPhase::RunExit,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
observed
|
||||||
|
.iter()
|
||||||
|
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observed
|
||||||
|
.iter()
|
||||||
|
.map(|(invocation, _)| invocation.counters.invocation.get())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[0, 1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||||
|
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||||
|
assert_eq!(observed[1].1, ["1:user"]);
|
||||||
|
assert_eq!(observed[2].1, ["1:user"]);
|
||||||
|
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
|
||||||
|
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_failure_does_not_make_item_live() {
|
fn append_failure_does_not_make_item_live() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
use agen::interceptor::{
|
use agen::interceptor::{
|
||||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
|
||||||
|
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
|
||||||
|
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
|
||||||
|
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
|
||||||
|
TurnEndAction,
|
||||||
|
};
|
||||||
|
use agen::llm_client::{
|
||||||
|
ClientError, LlmClient, Request, ResponseStream,
|
||||||
|
event::{Event, ResponseStatus, StatusEvent},
|
||||||
};
|
};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
|
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -613,12 +620,15 @@ struct YieldOnce {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for YieldOnce {
|
impl Interceptor for YieldOnce {
|
||||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(
|
||||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
&self,
|
||||||
|
_context: PreLlmRequestContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
PreRequestAction::Yield
|
PreRequestAction::Yield
|
||||||
} else {
|
} else {
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,12 +638,15 @@ struct PauseToolOnce {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for PauseToolOnce {
|
impl Interceptor for PauseToolOnce {
|
||||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
&self,
|
||||||
|
_info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
PreToolAction::Pause
|
PreToolAction::Pause
|
||||||
} else {
|
} else {
|
||||||
PreToolAction::Continue
|
PreToolAction::Continue
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ContinueTurnOnce {
|
impl Interceptor for ContinueTurnOnce {
|
||||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
async fn on_assistant_turn_end(
|
||||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
&self,
|
||||||
|
_context: AssistantTurnEndContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
||||||
} else {
|
} else {
|
||||||
TurnEndAction::Finish
|
TurnEndAction::Finish
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FailingLifecycleInterceptor {
|
||||||
|
failure: InterceptorPoint,
|
||||||
|
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailingLifecycleInterceptor {
|
||||||
|
fn new(failure: InterceptorPoint) -> Self {
|
||||||
|
Self {
|
||||||
|
failure,
|
||||||
|
calls: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
|
||||||
|
self.calls.lock().unwrap().push(point);
|
||||||
|
if self.failure == point {
|
||||||
|
Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Policy,
|
||||||
|
format!("{point} rejected"),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<InterceptorPoint> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for FailingLifecycleInterceptor {
|
||||||
|
async fn on_prompt_submit(
|
||||||
|
&self,
|
||||||
|
_context: PromptSubmitContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<PromptAction> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_history_appends(
|
||||||
|
&self,
|
||||||
|
_context: PendingHistoryAppendsContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<Vec<Item>> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pre_llm_request(
|
||||||
|
&self,
|
||||||
|
_context: PreLlmRequestContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
_info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_tool_call(
|
||||||
|
&self,
|
||||||
|
_info: &ToolResultInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
context: AssistantTurnEndContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(context.history.ends_with(context.assistant_entries));
|
||||||
|
if !context.tool_calls.is_empty() {
|
||||||
|
assert_eq!(
|
||||||
|
context
|
||||||
|
.assistant_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
|
||||||
|
.count(),
|
||||||
|
context.tool_calls.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
self.record(InterceptorPoint::RunExit, ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
|
||||||
|
use InterceptorPoint as Point;
|
||||||
|
|
||||||
|
let mut calls = match failure {
|
||||||
|
Point::PromptSubmit => vec![Point::PromptSubmit],
|
||||||
|
Point::PendingHistoryAppends => {
|
||||||
|
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
|
||||||
|
}
|
||||||
|
Point::PreLlmRequest => vec![
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
],
|
||||||
|
Point::PreToolCall => vec![
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
Point::AssistantTurnEnd,
|
||||||
|
Point::PreToolCall,
|
||||||
|
],
|
||||||
|
Point::PostToolCall => vec![
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
Point::AssistantTurnEnd,
|
||||||
|
Point::PreToolCall,
|
||||||
|
Point::PostToolCall,
|
||||||
|
],
|
||||||
|
Point::AssistantTurnEnd => vec![
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
Point::AssistantTurnEnd,
|
||||||
|
],
|
||||||
|
Point::RunExit => vec![
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
Point::AssistantTurnEnd,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
calls.push(Point::RunExit);
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
|
||||||
|
use InterceptorPoint as Point;
|
||||||
|
|
||||||
|
for failure_point in [
|
||||||
|
Point::PromptSubmit,
|
||||||
|
Point::PendingHistoryAppends,
|
||||||
|
Point::PreLlmRequest,
|
||||||
|
Point::PreToolCall,
|
||||||
|
Point::PostToolCall,
|
||||||
|
Point::AssistantTurnEnd,
|
||||||
|
Point::RunExit,
|
||||||
|
] {
|
||||||
|
let interceptor = FailingLifecycleInterceptor::new(failure_point);
|
||||||
|
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
|
||||||
|
let events = if needs_tool {
|
||||||
|
vec![
|
||||||
|
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||||
|
Event::tool_input_delta(0, "{}"),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
completed_text_events()
|
||||||
|
};
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||||
|
engine.register_tool(CountingTool::new("count_tool").definition());
|
||||||
|
engine.set_interceptor(interceptor.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
let exit = engine.run(&mut history, "test").await;
|
||||||
|
let failure = if failure_point == Point::RunExit {
|
||||||
|
assert!(matches!(exit, EngineRunExit::Finished));
|
||||||
|
engine
|
||||||
|
.last_run_exit_observer_failure()
|
||||||
|
.expect("terminal observer diagnostic should be retained")
|
||||||
|
} else {
|
||||||
|
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||||
|
EngineError::Interceptor(failure),
|
||||||
|
)) = &exit
|
||||||
|
else {
|
||||||
|
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
|
||||||
|
};
|
||||||
|
failure
|
||||||
|
};
|
||||||
|
assert_eq!(failure.phase(), failure_point);
|
||||||
|
assert_eq!(
|
||||||
|
failure.error().diagnostic(),
|
||||||
|
format!("{failure_point} rejected")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
interceptor.calls(),
|
||||||
|
expected_interceptor_calls(failure_point)
|
||||||
|
);
|
||||||
|
if failure_point == Point::PostToolCall {
|
||||||
|
assert!(
|
||||||
|
history
|
||||||
|
.items()
|
||||||
|
.any(|item| matches!(item, Item::ToolResult { .. })),
|
||||||
|
"post-tool failure must not precede terminal output commit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
|
||||||
|
let error = InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Dependency,
|
||||||
|
"界".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
|
||||||
|
);
|
||||||
|
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
|
||||||
|
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.diagnostic()
|
||||||
|
.is_char_boundary(error.diagnostic().len())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailingRunExitObserver {
|
||||||
|
pause: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for FailingRunExitObserver {
|
||||||
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
_context: AssistantTurnEndContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
Ok(if self.pause {
|
||||||
|
TurnEndAction::Pause
|
||||||
|
} else {
|
||||||
|
TurnEndAction::Finish
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||||
|
Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Dependency,
|
||||||
|
"terminal audit unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
|
||||||
|
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
|
||||||
|
let mut paused_history = History::new();
|
||||||
|
let mut paused_engine = paused_engine.lock(&paused_history);
|
||||||
|
assert!(matches!(
|
||||||
|
paused_engine.run(&mut paused_history, "pause").await,
|
||||||
|
EngineRunExit::Paused
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
paused_engine
|
||||||
|
.last_run_exit_observer_failure()
|
||||||
|
.expect("paused observer diagnostic")
|
||||||
|
.error()
|
||||||
|
.category(),
|
||||||
|
InterceptorErrorCategory::Dependency
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
interrupted_engine.set_max_turns(Some(0));
|
||||||
|
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
|
||||||
|
let mut interrupted_history = History::new();
|
||||||
|
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
|
||||||
|
assert!(matches!(
|
||||||
|
interrupted_engine
|
||||||
|
.run(&mut interrupted_history, "limit")
|
||||||
|
.await,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
interrupted_engine
|
||||||
|
.last_run_exit_observer_failure()
|
||||||
|
.expect("interrupted observer diagnostic")
|
||||||
|
.phase(),
|
||||||
|
InterceptorPoint::RunExit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TerminalMode {
|
||||||
|
Finish,
|
||||||
|
PauseOnce,
|
||||||
|
Yield,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RecordingTerminalInterceptor {
|
||||||
|
mode: TerminalMode,
|
||||||
|
assistant_turns: Arc<AtomicUsize>,
|
||||||
|
exits: Arc<Mutex<Vec<&'static str>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingTerminalInterceptor {
|
||||||
|
fn new(mode: TerminalMode) -> Self {
|
||||||
|
Self {
|
||||||
|
mode,
|
||||||
|
assistant_turns: Arc::new(AtomicUsize::new(0)),
|
||||||
|
exits: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exits(&self) -> Vec<&'static str> {
|
||||||
|
self.exits.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for RecordingTerminalInterceptor {
|
||||||
|
async fn pre_llm_request(
|
||||||
|
&self,
|
||||||
|
_context: PreLlmRequestContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
Ok(if self.mode == TerminalMode::Yield {
|
||||||
|
PreRequestAction::Yield
|
||||||
|
} else {
|
||||||
|
PreRequestAction::Continue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
context: AssistantTurnEndContext<'_, ()>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
assert!(!context.assistant_entries.is_empty());
|
||||||
|
assert!(
|
||||||
|
context.history.ends_with(context.assistant_entries),
|
||||||
|
"assistant-turn callback must observe committed terminal items"
|
||||||
|
);
|
||||||
|
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
|
||||||
|
TurnEndAction::Pause
|
||||||
|
} else {
|
||||||
|
TurnEndAction::Finish
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||||
|
let kind = match context.exit {
|
||||||
|
EngineRunExit::Finished => "finished",
|
||||||
|
EngineRunExit::Paused => "paused",
|
||||||
|
EngineRunExit::Yielded => "yielded",
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
|
||||||
|
};
|
||||||
|
self.exits.lock().unwrap().push(kind);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ContextWindowClient;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmClient for ContextWindowClient {
|
||||||
|
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||||
|
Err(ClientError::ContextWindowExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
|
||||||
|
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
engine.set_interceptor(finished.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "finish").await,
|
||||||
|
EngineRunExit::Finished
|
||||||
|
));
|
||||||
|
assert_eq!(finished.exits(), ["finished"]);
|
||||||
|
|
||||||
|
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
engine.set_interceptor(yielded.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "yield").await,
|
||||||
|
EngineRunExit::Yielded
|
||||||
|
));
|
||||||
|
assert_eq!(yielded.exits(), ["yielded"]);
|
||||||
|
|
||||||
|
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
engine.set_max_turns(Some(0));
|
||||||
|
engine.set_interceptor(limited.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "limit").await,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||||
|
));
|
||||||
|
assert_eq!(limited.exits(), ["limit"]);
|
||||||
|
|
||||||
|
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
engine.set_interceptor(cancelled.clone());
|
||||||
|
engine.cancel();
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "cancel").await,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||||
|
));
|
||||||
|
assert_eq!(cancelled.exits(), ["cancelled"]);
|
||||||
|
|
||||||
|
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||||
|
let mut engine = Engine::new(ContextWindowClient);
|
||||||
|
engine.set_interceptor(context.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "context").await,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
|
||||||
|
));
|
||||||
|
assert_eq!(context.exits(), ["context"]);
|
||||||
|
|
||||||
|
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
|
||||||
|
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
engine.set_interceptor(unexpected.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
assert!(matches!(
|
||||||
|
engine.lock(&history).run(&mut history, "fail").await,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
|
||||||
|
_
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
unexpected
|
||||||
|
.calls()
|
||||||
|
.iter()
|
||||||
|
.filter(|point| **point == InterceptorPoint::RunExit)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_observer_does_not_duplicate_on_resume() {
|
||||||
|
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
|
||||||
|
let first_response = vec![
|
||||||
|
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||||
|
Event::tool_input_delta(0, "{}"),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
|
||||||
|
let tool = CountingTool::new("count_tool");
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.register_tool(tool.definition());
|
||||||
|
engine.set_interceptor(interceptor.clone());
|
||||||
|
let mut history = History::new();
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
engine.run(&mut history, "pause").await,
|
||||||
|
EngineRunExit::Paused
|
||||||
|
));
|
||||||
|
assert_eq!(interceptor.exits(), ["paused"]);
|
||||||
|
assert_eq!(
|
||||||
|
tool.call_count(),
|
||||||
|
0,
|
||||||
|
"pause must retain the pending tool phase"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
engine.resume(&mut history).await,
|
||||||
|
EngineRunExit::Finished
|
||||||
|
));
|
||||||
|
assert_eq!(interceptor.exits(), ["paused", "finished"]);
|
||||||
|
assert_eq!(
|
||||||
|
tool.call_count(),
|
||||||
|
1,
|
||||||
|
"resume must execute the retained tool once"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
use agen::interceptor::{
|
||||||
|
Interceptor, InterceptorError, InterceptorErrorCategory, InterceptorPhase, InterceptorResult,
|
||||||
|
PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
||||||
|
};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::tool::{
|
use agen::tool::{
|
||||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||||
ToolResultDisposition,
|
ToolResultDisposition,
|
||||||
};
|
};
|
||||||
use agen::{Engine, History, Item, ToolExecutionPolicy};
|
use agen::{
|
||||||
|
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
|
||||||
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ContextPolicy {
|
impl Interceptor for ContextPolicy {
|
||||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
self.pre_contexts.lock().unwrap().push(info.context.clone());
|
self.pre_contexts.lock().unwrap().push(info.context.clone());
|
||||||
match info.call.name.as_str() {
|
Ok(match info.call.name.as_str() {
|
||||||
"skip_tool" => PreToolAction::Skip,
|
"skip_tool" => PreToolAction::Skip,
|
||||||
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
|
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
|
||||||
&info.call.id,
|
&info.call.id,
|
||||||
ToolOutput::from("synthetic result".to_string()),
|
ToolOutput::from("synthetic result".to_string()),
|
||||||
)),
|
)),
|
||||||
_ => PreToolAction::Continue,
|
_ => PreToolAction::Continue,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &ToolResultInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
self.post_contexts
|
self.post_contexts
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(info.context.clone());
|
.push(info.context.clone());
|
||||||
PostToolAction::Continue
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for BlockingPolicy {
|
impl Interceptor for BlockingPolicy {
|
||||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
if info.call.name == "blocked_tool" {
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
Ok(if info.call.name == "blocked_tool" {
|
||||||
PreToolAction::Skip
|
PreToolAction::Skip
|
||||||
} else {
|
} else {
|
||||||
PreToolAction::Continue
|
PreToolAction::Continue
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,9 +1035,9 @@ async fn test_before_tool_call_skip() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hook: post_tool_call - verify that results can be modified
|
/// Hook: post_tool_call - verify that the committed terminal result is observed.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_post_tool_call_modification() {
|
async fn test_post_tool_call_observes_committed_result() {
|
||||||
// Prepare responses for multiple requests
|
// Prepare responses for multiple requests
|
||||||
let client = MockLlmClient::with_responses(vec![
|
let client = MockLlmClient::with_responses(vec![
|
||||||
// First request: tool call
|
// First request: tool call
|
||||||
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
|
|||||||
|
|
||||||
engine.register_tool(simple_tool_definition());
|
engine.register_tool(simple_tool_definition());
|
||||||
|
|
||||||
// Policy to modify results
|
// Policy to observe the committed terminal result.
|
||||||
struct ModifyingPolicy {
|
struct ObservingPolicy {
|
||||||
modified_content: Arc<std::sync::Mutex<Option<String>>>,
|
observed_content: Arc<std::sync::Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ModifyingPolicy {
|
impl Interceptor for ObservingPolicy {
|
||||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
info.result.summary = format!("[Modified] {}", info.result.summary);
|
&self,
|
||||||
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
|
info: &ToolResultInfo<'_, ()>,
|
||||||
PostToolAction::Continue
|
) -> InterceptorResult<PostToolAction> {
|
||||||
|
assert_eq!(info.invocation.phase, InterceptorPhase::PostToolCall);
|
||||||
|
assert_eq!(
|
||||||
|
info.invocation.call_id,
|
||||||
|
Some(agen::InterceptorCallId::Tool(info.call.id.clone()))
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
info.history.last().map(|entry| &entry.item),
|
||||||
|
Some(Item::ToolResult { call_id, .. }) if call_id == &info.call.id
|
||||||
|
));
|
||||||
|
*self.observed_content.lock().unwrap() = Some(info.result.summary.clone());
|
||||||
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let modified_content = Arc::new(std::sync::Mutex::new(None));
|
let observed_content = Arc::new(std::sync::Mutex::new(None));
|
||||||
engine.set_interceptor(ModifyingPolicy {
|
engine.set_interceptor(ObservingPolicy {
|
||||||
modified_content: modified_content.clone(),
|
observed_content: observed_content.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run(&mut history, "Test modification").await;
|
let result = engine.run(&mut history, "Test observation").await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result.result, agen::EngineRunExit::Finished),
|
matches!(result.result, agen::EngineRunExit::Finished),
|
||||||
"Engine should complete"
|
"Engine should complete"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify hook was called and content was modified
|
// Verify the interceptor observed the exact committed result.
|
||||||
let content = modified_content.lock().unwrap().clone();
|
let observed = observed_content.lock().unwrap().clone();
|
||||||
assert!(content.is_some(), "Hook should have been called");
|
assert_eq!(observed.as_deref(), Some("Original Result"));
|
||||||
assert!(
|
assert!(history.items().any(|item| matches!(
|
||||||
content.unwrap().contains("[Modified]"),
|
item,
|
||||||
"Result should be modified"
|
Item::ToolResult { summary, .. } if summary == "Original Result"
|
||||||
);
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
||||||
@@ -1143,11 +1168,14 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for SyntheticPolicy {
|
impl Interceptor for SyntheticPolicy {
|
||||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
PreToolAction::SyntheticResult(ToolResult::error(
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
Ok(PreToolAction::SyntheticResult(ToolResult::error(
|
||||||
info.call.id.clone(),
|
info.call.id.clone(),
|
||||||
"permission denied",
|
"permission denied",
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,6 +1195,80 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum InvalidIdentityMode {
|
||||||
|
ContinuedCall,
|
||||||
|
SyntheticResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InvalidIdentityPolicy(InvalidIdentityMode);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for InvalidIdentityPolicy {
|
||||||
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
assert_eq!(info.invocation.phase, InterceptorPhase::PreToolCall);
|
||||||
|
assert_eq!(
|
||||||
|
info.invocation.call_id,
|
||||||
|
Some(agen::InterceptorCallId::Tool("call_1".to_string()))
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
info.history.last().map(|entry| &entry.item),
|
||||||
|
Some(Item::ToolCall { call_id, .. }) if call_id == "call_1"
|
||||||
|
));
|
||||||
|
Ok(match self.0 {
|
||||||
|
InvalidIdentityMode::ContinuedCall => {
|
||||||
|
info.call.id = "different-call".to_string();
|
||||||
|
PreToolAction::Continue
|
||||||
|
}
|
||||||
|
InvalidIdentityMode::SyntheticResult => PreToolAction::SyntheticResult(
|
||||||
|
ToolResult::error("different-call", "invalid synthetic result"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interceptor_cannot_change_tool_call_identity() {
|
||||||
|
for mode in [
|
||||||
|
InvalidIdentityMode::ContinuedCall,
|
||||||
|
InvalidIdentityMode::SyntheticResult,
|
||||||
|
] {
|
||||||
|
let client = MockLlmClient::new(vec![
|
||||||
|
Event::tool_use_start(0, "call_1", "echo"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.register_tool(SlowTool::new("echo", 1).definition());
|
||||||
|
engine.set_interceptor(InvalidIdentityPolicy(mode));
|
||||||
|
let mut history = History::new();
|
||||||
|
|
||||||
|
let result = engine.run(&mut history, "identity").await;
|
||||||
|
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||||
|
EngineError::Interceptor(failure),
|
||||||
|
)) = result.result
|
||||||
|
else {
|
||||||
|
panic!("invalid tool identity must interrupt with a typed failure");
|
||||||
|
};
|
||||||
|
assert_eq!(failure.phase(), InterceptorPhase::PreToolCall);
|
||||||
|
assert_eq!(
|
||||||
|
failure.error().category(),
|
||||||
|
InterceptorErrorCategory::ContractViolation
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!history
|
||||||
|
.items()
|
||||||
|
.any(|item| matches!(item, Item::ToolResult { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||||
let client = MockLlmClient::new(vec![
|
let client = MockLlmClient::new(vec![
|
||||||
@@ -1181,16 +1283,24 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
|||||||
let tool = SlowTool::new("confirmed", 1);
|
let tool = SlowTool::new("confirmed", 1);
|
||||||
engine.register_tool(tool.definition());
|
engine.register_tool(tool.definition());
|
||||||
|
|
||||||
struct AbortAfterResult;
|
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||||
|
struct AbortAfterResult {
|
||||||
|
lifecycle: Arc<Mutex<Vec<&'static str>>>,
|
||||||
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for AbortAfterResult {
|
impl Interceptor for AbortAfterResult {
|
||||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
PostToolAction::Abort("policy stopped the run".to_string())
|
&self,
|
||||||
|
_info: &ToolResultInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
|
self.lifecycle.lock().unwrap().push("post_tool_call");
|
||||||
|
Ok(PostToolAction::Abort("policy stopped the run".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
engine.set_interceptor(AbortAfterResult);
|
engine.set_interceptor(AbortAfterResult {
|
||||||
|
lifecycle: observed.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
|
||||||
let published = observed.clone();
|
let published = observed.clone();
|
||||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||||
let committed = observed.clone();
|
let committed = observed.clone();
|
||||||
@@ -1210,7 +1320,7 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
|||||||
assert_eq!(tool.call_count(), 1);
|
assert_eq!(tool.call_count(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed.lock().unwrap().as_slice(),
|
observed.lock().unwrap().as_slice(),
|
||||||
["committed", "published", "run-returned"]
|
["committed", "published", "post_tool_call", "run-returned"]
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
output.result,
|
output.result,
|
||||||
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
|||||||
} if call_id == "call_confirmed"
|
} if call_id == "call_confirmed"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum PostToolStopMode {
|
||||||
|
Abort,
|
||||||
|
Failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StopFirstParallelResult(PostToolStopMode);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for StopFirstParallelResult {
|
||||||
|
async fn post_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &ToolResultInfo<'_, ()>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
|
if info.call.id != "call_fast" {
|
||||||
|
return Ok(PostToolAction::Continue);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
match self.0 {
|
||||||
|
PostToolStopMode::Abort => Ok(PostToolAction::Abort("stop parallel batch".to_string())),
|
||||||
|
PostToolStopMode::Failure => Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Policy,
|
||||||
|
"reject parallel batch",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning() {
|
||||||
|
for mode in [PostToolStopMode::Abort, PostToolStopMode::Failure] {
|
||||||
|
let first_response = vec![
|
||||||
|
Event::tool_use_start(0, "call_fast", "fast"),
|
||||||
|
Event::tool_input_delta(0, r#"{}"#),
|
||||||
|
Event::tool_use_stop(0),
|
||||||
|
Event::tool_use_start(1, "call_ready", "ready"),
|
||||||
|
Event::tool_input_delta(1, r#"{}"#),
|
||||||
|
Event::tool_use_stop(1),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let second_response = vec![
|
||||||
|
Event::text_block_start(0),
|
||||||
|
Event::text_delta(0, "next run completed"),
|
||||||
|
Event::text_block_stop(0, None),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let client = MockLlmClient::with_responses(vec![first_response, second_response]);
|
||||||
|
let mut engine = Engine::new(client);
|
||||||
|
engine.register_tool(SlowTool::new("fast", 0).definition());
|
||||||
|
engine.register_tool(SlowTool::new("ready", 1).definition());
|
||||||
|
engine.set_interceptor(StopFirstParallelResult(mode));
|
||||||
|
let mut history = History::new();
|
||||||
|
|
||||||
|
let output = engine.run(&mut history, "parallel stop").await;
|
||||||
|
match mode {
|
||||||
|
PostToolStopMode::Abort => assert!(matches!(
|
||||||
|
output.result,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||||
|
EngineError::Aborted(ref reason)
|
||||||
|
)) if reason == "stop parallel batch"
|
||||||
|
)),
|
||||||
|
PostToolStopMode::Failure => assert!(matches!(
|
||||||
|
output.result,
|
||||||
|
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||||
|
EngineError::Interceptor(ref failure)
|
||||||
|
)) if failure.phase() == InterceptorPhase::PostToolCall
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminal_ids: Vec<_> = history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| match &entry.item {
|
||||||
|
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(terminal_ids.len(), 2);
|
||||||
|
assert!(terminal_ids.contains(&"call_fast"));
|
||||||
|
assert!(terminal_ids.contains(&"call_ready"));
|
||||||
|
|
||||||
|
let mut engine = output.engine;
|
||||||
|
let next = engine.run(&mut history, "next run").await;
|
||||||
|
assert!(matches!(next, EngineRunExit::Finished));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+167
-78
@@ -18,10 +18,11 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
|||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||||
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
|
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||||
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
||||||
WorkerManifest, WorkerMeta,
|
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||||
|
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
@@ -67,9 +68,6 @@ pub struct WorkerManifestConfig {
|
|||||||
/// First-class web tool opt-in. See [`WebConfig`].
|
/// First-class web tool opt-in. See [`WebConfig`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: Option<WebConfig>,
|
pub web: Option<WebConfig>,
|
||||||
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
|
||||||
#[serde(default)]
|
|
||||||
pub memory: Option<MemoryConfig>,
|
|
||||||
/// External Agent Skills directories. See [`crate::SkillsConfig`].
|
/// External Agent Skills directories. See [`crate::SkillsConfig`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: Option<SkillsConfig>,
|
pub skills: Option<SkillsConfig>,
|
||||||
@@ -193,18 +191,86 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct MemoryFeatureConfigPartial {
|
pub struct MemoryFeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub staging: Option<bool>,
|
pub staging_tools: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resident: Option<MemoryResidentProfileConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryResidentProfileConfigPartial {
|
||||||
|
#[serde(default)]
|
||||||
|
pub inject_summary: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryExtractionProfileConfigPartial {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<ModelManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub threshold: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub worker_max_turns: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryConsolidationProfileConfigPartial {
|
||||||
|
#[serde(default)]
|
||||||
|
pub request_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryFeatureConfigPartial {
|
impl MemoryFeatureConfigPartial {
|
||||||
fn merge(self, other: Self) -> Self {
|
fn merge(self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: other.enabled.or(self.enabled),
|
enabled: other.enabled.or(self.enabled),
|
||||||
staging: other.staging.or(self.staging),
|
staging_tools: other.staging_tools.or(self.staging_tools),
|
||||||
|
resident: merge_option(
|
||||||
|
self.resident,
|
||||||
|
other.resident,
|
||||||
|
MemoryResidentProfileConfigPartial::merge,
|
||||||
|
),
|
||||||
|
extraction: merge_option(
|
||||||
|
self.extraction,
|
||||||
|
other.extraction,
|
||||||
|
MemoryExtractionProfileConfigPartial::merge,
|
||||||
|
),
|
||||||
|
consolidation: merge_option(
|
||||||
|
self.consolidation,
|
||||||
|
other.consolidation,
|
||||||
|
MemoryConsolidationProfileConfigPartial::merge,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryResidentProfileConfigPartial {
|
||||||
|
fn merge(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
inject_summary: other.inject_summary.or(self.inject_summary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryExtractionProfileConfigPartial {
|
||||||
|
fn merge(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: other.enabled.or(self.enabled),
|
||||||
|
model: other.model.or(self.model),
|
||||||
|
threshold: other.threshold.or(self.threshold),
|
||||||
|
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,13 +319,21 @@ impl MergeRequestFeatureConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemoryConsolidationProfileConfigPartial {
|
||||||
|
fn merge(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
request_enabled: other.request_enabled.or(self.request_enabled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<FeatureConfigPartial> for FeatureConfig {
|
impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
fn from(value: FeatureConfigPartial) -> Self {
|
fn from(value: FeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
|
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
memory: value
|
memory: value
|
||||||
.memory
|
.memory
|
||||||
.map(MemoryFeatureConfig::from)
|
.map(ResolvedMemoryFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
@@ -329,20 +403,52 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
|
||||||
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
||||||
|
let resident = value.resident.unwrap_or_default();
|
||||||
|
let extraction = value.extraction.unwrap_or_default();
|
||||||
|
let consolidation = value.consolidation.unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
enabled: value.enabled.unwrap_or_default(),
|
profile: MemoryFeatureProfileConfig {
|
||||||
staging: value.staging.unwrap_or_default(),
|
enabled: value.enabled.unwrap_or_default(),
|
||||||
|
staging_tools: value.staging_tools.unwrap_or_default(),
|
||||||
|
resident: MemoryResidentProfileConfig {
|
||||||
|
inject_summary: resident.inject_summary.unwrap_or(true),
|
||||||
|
},
|
||||||
|
extraction: MemoryExtractionProfileConfig {
|
||||||
|
enabled: extraction.enabled.unwrap_or(true),
|
||||||
|
model: extraction.model,
|
||||||
|
threshold: extraction.threshold.or(Some(50_000)),
|
||||||
|
worker_max_turns: extraction
|
||||||
|
.worker_max_turns
|
||||||
|
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||||
|
},
|
||||||
|
consolidation: MemoryConsolidationProfileConfig {
|
||||||
|
request_enabled: consolidation.request_enabled.unwrap_or(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace_settings: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||||
fn from(value: MemoryFeatureConfig) -> Self {
|
fn from(value: ResolvedMemoryFeatureConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: Some(value.enabled),
|
enabled: Some(value.profile.enabled),
|
||||||
staging: Some(value.staging),
|
staging_tools: Some(value.profile.staging_tools),
|
||||||
|
resident: Some(MemoryResidentProfileConfigPartial {
|
||||||
|
inject_summary: Some(value.profile.resident.inject_summary),
|
||||||
|
}),
|
||||||
|
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||||
|
enabled: Some(value.profile.extraction.enabled),
|
||||||
|
model: value.profile.extraction.model,
|
||||||
|
threshold: value.profile.extraction.threshold,
|
||||||
|
worker_max_turns: value.profile.extraction.worker_max_turns,
|
||||||
|
}),
|
||||||
|
consolidation: Some(MemoryConsolidationProfileConfigPartial {
|
||||||
|
request_enabled: Some(value.profile.consolidation.request_enabled),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,13 +649,9 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
|||||||
(removed; use compaction.prune_protected_tokens)",
|
(removed; use compaction.prune_protected_tokens)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if value
|
if value.get("memory").is_some() {
|
||||||
.get("memory")
|
|
||||||
.and_then(toml::Value::as_table)
|
|
||||||
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
|
|
||||||
{
|
|
||||||
return Err(toml::de::Error::custom(
|
return Err(toml::de::Error::custom(
|
||||||
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
|
"unknown field in manifest: memory (removed; configure feature.memory)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if value
|
if value
|
||||||
@@ -633,11 +735,6 @@ impl WorkerManifestConfig {
|
|||||||
for rule in &mut self.delegation_scope.deny {
|
for rule in &mut self.delegation_scope.deny {
|
||||||
rule.target = join_if_relative(base, &rule.target);
|
rule.target = join_if_relative(base, &rule.target);
|
||||||
}
|
}
|
||||||
if let Some(ref mut memory) = self.memory
|
|
||||||
&& let Some(ref mut root) = memory.workspace_root
|
|
||||||
{
|
|
||||||
*root = join_if_relative(base, root);
|
|
||||||
}
|
|
||||||
if let Some(ref mut compaction) = self.compaction
|
if let Some(ref mut compaction) = self.compaction
|
||||||
&& let Some(ref mut cp) = compaction.model
|
&& let Some(ref mut cp) = compaction.model
|
||||||
{
|
{
|
||||||
@@ -682,7 +779,6 @@ impl WorkerManifestConfig {
|
|||||||
CompactionConfigPartial::merge,
|
CompactionConfigPartial::merge,
|
||||||
),
|
),
|
||||||
web: merge_option(self.web, upper.web, WebConfig::merge),
|
web: merge_option(self.web, upper.web, WebConfig::merge),
|
||||||
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
|
|
||||||
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,32 +850,6 @@ impl crate::WebFetchConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryConfig {
|
|
||||||
fn merge(self, upper: Self) -> Self {
|
|
||||||
Self {
|
|
||||||
workspace_root: upper.workspace_root.or(self.workspace_root),
|
|
||||||
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
|
|
||||||
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
|
|
||||||
inject_summary: upper.inject_summary.or(self.inject_summary),
|
|
||||||
workspace_id: upper.workspace_id.or(self.workspace_id),
|
|
||||||
settings_revision: upper.settings_revision.or(self.settings_revision),
|
|
||||||
language: upper.language.or(self.language),
|
|
||||||
extract_model: upper.extract_model.or(self.extract_model),
|
|
||||||
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
|
|
||||||
extract_worker_max_turns: upper
|
|
||||||
.extract_worker_max_turns
|
|
||||||
.or(self.extract_worker_max_turns),
|
|
||||||
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
|
|
||||||
consolidation_threshold_files: upper
|
|
||||||
.consolidation_threshold_files
|
|
||||||
.or(self.consolidation_threshold_files),
|
|
||||||
consolidation_threshold_bytes: upper
|
|
||||||
.consolidation_threshold_bytes
|
|
||||||
.or(self.consolidation_threshold_bytes),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkerMetaConfig {
|
impl WorkerMetaConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1223,7 +1293,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
|||||||
mcp: cfg.mcp,
|
mcp: cfg.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: cfg.web,
|
web: cfg.web,
|
||||||
memory: cfg.memory,
|
|
||||||
skills: cfg.skills,
|
skills: cfg.skills,
|
||||||
profile: None,
|
profile: None,
|
||||||
})
|
})
|
||||||
@@ -1271,7 +1340,6 @@ mod tests {
|
|||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
web: None,
|
web: None,
|
||||||
memory: None,
|
|
||||||
skills: None,
|
skills: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1846,29 +1914,50 @@ prune_protected_turns = 3
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
|
fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
|
||||||
let bad = r#"
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
[memory]
|
r#"
|
||||||
extract_worker_max_input_tokens = 30000
|
[feature.memory]
|
||||||
"#;
|
enabled = true
|
||||||
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
staging_tools = false
|
||||||
assert!(
|
|
||||||
err.to_string()
|
[feature.memory.resident]
|
||||||
.contains("memory.extract_worker_max_input_tokens"),
|
inject_summary = false
|
||||||
"unexpected error: {err}"
|
|
||||||
);
|
[feature.memory.extraction]
|
||||||
|
enabled = true
|
||||||
|
threshold = 42000
|
||||||
|
worker_max_turns = 2
|
||||||
|
|
||||||
|
[feature.memory.consolidation]
|
||||||
|
request_enabled = false
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let memory = cfg.feature.memory.unwrap();
|
||||||
|
assert_eq!(memory.enabled, Some(true));
|
||||||
|
assert_eq!(memory.staging_tools, Some(false));
|
||||||
|
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
|
||||||
|
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
|
||||||
|
let extraction = memory.extraction.unwrap();
|
||||||
|
assert_eq!(extraction.enabled, Some(true));
|
||||||
|
assert_eq!(extraction.threshold, Some(42_000));
|
||||||
|
assert_eq!(extraction.worker_max_turns, Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_extract_worker_max_turns() {
|
fn from_toml_rejects_legacy_top_level_memory_authority() {
|
||||||
let cfg = WorkerManifestConfig::from_toml(
|
let err = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[memory]
|
[memory]
|
||||||
extract_worker_max_turns = 2
|
extract_worker_max_turns = 2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap_err();
|
||||||
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
|
assert!(
|
||||||
|
err.to_string().contains("memory"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1948,7 +2037,7 @@ worker_max_turns = 7
|
|||||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert!(!manifest.feature.task.enabled);
|
assert!(!manifest.feature.task.enabled);
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.profile.enabled);
|
||||||
assert!(!manifest.feature.web.enabled);
|
assert!(!manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.sub_worker.enabled);
|
assert!(!manifest.feature.sub_worker.enabled);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
@@ -2025,8 +2114,8 @@ enabled = false
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(!manifest.feature.orchestration.enabled);
|
assert!(!manifest.feature.orchestration.enabled);
|
||||||
assert!(!manifest.feature.memory.enabled);
|
assert!(!manifest.feature.memory.profile.enabled);
|
||||||
assert!(!manifest.feature.memory.staging);
|
assert!(!manifest.feature.memory.profile.staging_tools);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2074,7 +2163,7 @@ readiness_check = true
|
|||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
staging = true
|
staging_tools = true
|
||||||
|
|
||||||
[feature.manage_workdir]
|
[feature.manage_workdir]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -2111,8 +2200,8 @@ enabled = true
|
|||||||
})
|
})
|
||||||
.try_into()
|
.try_into()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(manifest.feature.memory.enabled);
|
assert!(manifest.feature.memory.profile.enabled);
|
||||||
assert!(manifest.feature.memory.staging);
|
assert!(manifest.feature.memory.profile.staging_tools);
|
||||||
assert!(manifest.feature.manage_workdir.enabled);
|
assert!(manifest.feature.manage_workdir.enabled);
|
||||||
assert!(manifest.feature.ticket.enabled);
|
assert!(manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.ticket.authoring);
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
|
|||||||
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
|
|||||||
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
||||||
|
|
||||||
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
|
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
|
||||||
/// See [`crate::MemoryConfig::extract_worker_max_turns`].
|
/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
|
||||||
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
||||||
|
|||||||
+506
-147
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||||
/// at construction time.
|
/// at construction time.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkerManifest {
|
pub struct WorkerManifest {
|
||||||
pub worker: WorkerMeta,
|
pub worker: WorkerMeta,
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
@@ -80,11 +81,6 @@ pub struct WorkerManifest {
|
|||||||
pub mcp: McpConfig,
|
pub mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfig>,
|
pub compaction: Option<CompactionConfig>,
|
||||||
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
|
||||||
/// storage, extraction, consolidation, and resident injection, but memory
|
|
||||||
/// tools are surfaced only when `[feature.memory].enabled = true`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub memory: Option<MemoryConfig>,
|
|
||||||
/// First-class web tools configuration. Network access remains fail-closed
|
/// First-class web tools configuration. Network access remains fail-closed
|
||||||
/// under this config; WebSearch/WebFetch schemas are surfaced only when
|
/// under this config; WebSearch/WebFetch schemas are surfaced only when
|
||||||
/// `[feature.web].enabled = true`.
|
/// `[feature.web].enabled = true`.
|
||||||
@@ -109,12 +105,12 @@ pub struct WorkerManifest {
|
|||||||
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
||||||
/// sessions, secrets, or resolved host state. Tool registration still applies
|
/// sessions, secrets, or resolved host state. Tool registration still applies
|
||||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct FeatureConfig {
|
pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task: FeatureFlagConfig,
|
pub task: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: MemoryFeatureConfig,
|
pub memory: ResolvedMemoryFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: FeatureFlagConfig,
|
pub web: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -147,7 +143,7 @@ impl Default for FeatureConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
task: FeatureFlagConfig::disabled(),
|
task: FeatureFlagConfig::disabled(),
|
||||||
memory: MemoryFeatureConfig::disabled(),
|
memory: ResolvedMemoryFeatureConfig::default(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
image: FeatureFlagConfig::disabled(),
|
image: FeatureFlagConfig::disabled(),
|
||||||
sub_worker: FeatureFlagConfig::disabled(),
|
sub_worker: FeatureFlagConfig::disabled(),
|
||||||
@@ -222,34 +218,139 @@ const fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct MemoryFeatureConfig {
|
#[serde(default, deny_unknown_fields)]
|
||||||
#[serde(default)]
|
pub struct MemoryFeatureProfileConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
|
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
|
||||||
#[serde(default)]
|
pub staging_tools: bool,
|
||||||
pub staging: bool,
|
pub resident: MemoryResidentProfileConfig,
|
||||||
|
pub extraction: MemoryExtractionProfileConfig,
|
||||||
|
pub consolidation: MemoryConsolidationProfileConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryFeatureConfig {
|
impl MemoryFeatureProfileConfig {
|
||||||
pub const fn disabled() -> Self {
|
pub fn disabled() -> Self {
|
||||||
Self {
|
Self::default()
|
||||||
enabled: false,
|
|
||||||
staging: false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn enabled() -> Self {
|
pub fn enabled() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
staging: false,
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MemoryFeatureConfig {
|
impl Default for MemoryFeatureProfileConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::disabled()
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
staging_tools: false,
|
||||||
|
resident: MemoryResidentProfileConfig::default(),
|
||||||
|
extraction: MemoryExtractionProfileConfig::default(),
|
||||||
|
consolidation: MemoryConsolidationProfileConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct MemoryResidentProfileConfig {
|
||||||
|
pub inject_summary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryResidentProfileConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inject_summary: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct MemoryExtractionProfileConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub model: Option<ModelManifest>,
|
||||||
|
pub threshold: Option<u64>,
|
||||||
|
pub worker_max_turns: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryExtractionProfileConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
model: None,
|
||||||
|
threshold: Some(50_000),
|
||||||
|
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct MemoryConsolidationProfileConfig {
|
||||||
|
pub request_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryConsolidationProfileConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
request_enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct ResolvedMemoryFeatureConfig {
|
||||||
|
pub profile: MemoryFeatureProfileConfig,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedMemoryFeatureConfig {
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.profile.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_workspace_settings(
|
||||||
|
&mut self,
|
||||||
|
settings: WorkspaceMemorySettingsSnapshot,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
if !self.profile.enabled {
|
||||||
|
if self.workspace_settings.is_some() {
|
||||||
|
return Err("disabled Memory feature must not carry Workspace settings");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.workspace_settings.is_some() {
|
||||||
|
return Err("memory Workspace settings are already bound");
|
||||||
|
}
|
||||||
|
self.workspace_settings = Some(settings);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||||
|
self.workspace_settings.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
||||||
|
if self.profile.enabled && self.workspace_settings.is_none() {
|
||||||
|
return Err("enabled Memory feature requires trusted Workspace settings");
|
||||||
|
}
|
||||||
|
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||||
|
return Err("disabled Memory feature must not carry Workspace settings");
|
||||||
|
}
|
||||||
|
if let Some(settings) = &self.workspace_settings
|
||||||
|
&& (settings.settings_revision == 0
|
||||||
|
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||||
|
{
|
||||||
|
return Err("Memory Workspace settings snapshot metadata is invalid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,98 +585,6 @@ pub struct WorkspaceMemorySettingsSnapshot {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Memory subsystem configuration. Presence in the manifest enables
|
|
||||||
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
|
||||||
/// is absent, memory resolution searches upward from the Worker's pwd for a
|
|
||||||
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
|
||||||
/// as a memory root.
|
|
||||||
///
|
|
||||||
/// All fields are `Option`; defaults are applied at the consumer
|
|
||||||
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
|
|
||||||
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryConfig {
|
|
||||||
/// Override for the memory workspace root. When `None`, consumers resolve
|
|
||||||
/// the root from their default path and ancestor `.yoi/memory` markers.
|
|
||||||
/// When set, must be an absolute path.
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_root: Option<PathBuf>,
|
|
||||||
/// Maximum number of records returned by `MemoryQuery` /
|
|
||||||
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
|
|
||||||
#[serde(default)]
|
|
||||||
pub query_result_limit: Option<usize>,
|
|
||||||
/// Lines of context before and after each match in query excerpts.
|
|
||||||
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
|
|
||||||
#[serde(default)]
|
|
||||||
pub query_excerpt_lines: Option<usize>,
|
|
||||||
/// Whether the body of `memory/summary.md` is exposed in the resident
|
|
||||||
/// system-prompt section. `None` ⇒ enabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub inject_summary: Option<bool>,
|
|
||||||
/// Workspace that owns the bound Memory settings revision.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub workspace_id: Option<String>,
|
|
||||||
/// Monotonic revision of the bound Workspace Memory settings.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub settings_revision: Option<u64>,
|
|
||||||
/// Language from the bound Workspace Memory settings revision.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub language: Option<String>,
|
|
||||||
/// Optional model for the extract worker. When `None`,
|
|
||||||
/// the main engine model is cloned via `clone_boxed()`. Lightweight
|
|
||||||
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
|
||||||
/// recommended.
|
|
||||||
#[serde(default)]
|
|
||||||
pub extract_model: Option<ModelManifest>,
|
|
||||||
/// Cumulative input-token threshold (since the last extract pointer)
|
|
||||||
/// that triggers an extract run. `None` disables the extract trigger
|
|
||||||
/// entirely; memory tools and resident injection still work, only
|
|
||||||
/// the auto-extract trigger is dormant.
|
|
||||||
#[serde(default)]
|
|
||||||
pub extract_threshold: Option<u64>,
|
|
||||||
/// Optional maximum extract-worker tool-loop depth. `None` leaves
|
|
||||||
/// the worker unlimited; the default bounds runaway short-context
|
|
||||||
/// loops. Falls through to
|
|
||||||
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
|
|
||||||
#[serde(default)]
|
|
||||||
pub extract_worker_max_turns: Option<u32>,
|
|
||||||
/// Optional model for the consolidation worker. When
|
|
||||||
/// `None`, the main engine model is cloned via `clone_boxed()`.
|
|
||||||
/// Reasoning-class models are recommended.
|
|
||||||
#[serde(default)]
|
|
||||||
pub consolidation_model: Option<ModelManifest>,
|
|
||||||
/// Consolidation trigger: file-count threshold of `_staging/`. The
|
|
||||||
/// consolidation run fires when the staging directory has at least
|
|
||||||
/// this many entries. Either threshold reaching its limit fires
|
|
||||||
/// consolidation (logical OR). `None` for both thresholds ⇒
|
|
||||||
/// consolidation disabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub consolidation_threshold_files: Option<usize>,
|
|
||||||
/// Consolidation trigger: byte-size threshold across all `_staging/`
|
|
||||||
/// entries. Either threshold reaching its limit fires consolidation.
|
|
||||||
/// `None` for both thresholds ⇒ consolidation disabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub consolidation_threshold_bytes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemoryConfig {
|
|
||||||
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
|
|
||||||
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
|
|
||||||
self.workspace_id = Some(snapshot.workspace_id.clone());
|
|
||||||
self.settings_revision = Some(snapshot.settings_revision);
|
|
||||||
self.language = Some(snapshot.language.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the complete bound Workspace settings snapshot, if every field is present.
|
|
||||||
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
|
||||||
Some(WorkspaceMemorySettingsSnapshot {
|
|
||||||
workspace_id: self.workspace_id.clone()?,
|
|
||||||
settings_revision: self.settings_revision?,
|
|
||||||
language: self.language.clone()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker metadata.
|
/// Worker metadata.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerMeta {
|
pub struct WorkerMeta {
|
||||||
@@ -931,6 +940,12 @@ impl Default for CompactionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerManifest {
|
impl WorkerManifest {
|
||||||
|
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||||
|
self.profile.is_some()
|
||||||
|
|| self.plugins.has_resolved_plan()
|
||||||
|
|| self.feature.memory.workspace_settings.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||||
config::reject_removed_manifest_fields(s)?;
|
config::reject_removed_manifest_fields(s)?;
|
||||||
@@ -941,6 +956,212 @@ impl WorkerManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct LegacyMemoryFeatureConfig {
|
||||||
|
enabled: bool,
|
||||||
|
staging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct LegacyMemoryConfig {
|
||||||
|
#[serde(rename = "workspace_root")]
|
||||||
|
_workspace_root: Option<PathBuf>,
|
||||||
|
#[serde(rename = "query_result_limit")]
|
||||||
|
_query_result_limit: Option<usize>,
|
||||||
|
#[serde(rename = "query_excerpt_lines")]
|
||||||
|
_query_excerpt_lines: Option<usize>,
|
||||||
|
inject_summary: Option<bool>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
settings_revision: Option<u64>,
|
||||||
|
language: Option<String>,
|
||||||
|
extract_model: Option<ModelManifest>,
|
||||||
|
extract_threshold: Option<u64>,
|
||||||
|
extract_worker_max_turns: Option<u32>,
|
||||||
|
consolidation_model: Option<ModelManifest>,
|
||||||
|
consolidation_threshold_files: Option<usize>,
|
||||||
|
consolidation_threshold_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
|
||||||
|
|
||||||
|
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
|
||||||
|
pub fn write_persisted_worker_manifest_snapshot(
|
||||||
|
manifest: &WorkerManifest,
|
||||||
|
) -> Result<serde_json::Value, serde_json::Error> {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
|
||||||
|
"manifest": serde_json::to_value(manifest)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a durable resolved Worker Manifest through the versioned compatibility
|
||||||
|
/// boundary. Runtime code must not deserialize persisted snapshots directly.
|
||||||
|
pub fn read_persisted_worker_manifest_snapshot(
|
||||||
|
snapshot: serde_json::Value,
|
||||||
|
) -> Result<WorkerManifest, serde_json::Error> {
|
||||||
|
let object = snapshot.as_object().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest snapshot must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if let Some(version) = object.get("schema_version") {
|
||||||
|
let version = version.as_u64().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest snapshot schema_version must be an integer",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if object.len() != 2 {
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest snapshot contains unknown fields",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let manifest = object.get("manifest").cloned().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest snapshot is missing manifest",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if manifest
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|manifest| manifest.contains_key("memory"))
|
||||||
|
{
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"current resolved Worker manifest contains removed top-level memory authority",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate_legacy_resolved_manifest_snapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_persisted_worker_manifest(
|
||||||
|
manifest: WorkerManifest,
|
||||||
|
) -> Result<WorkerManifest, serde_json::Error> {
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(|message| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
message,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_legacy_resolved_manifest_snapshot(
|
||||||
|
mut snapshot: serde_json::Value,
|
||||||
|
) -> Result<WorkerManifest, serde_json::Error> {
|
||||||
|
let root = snapshot.as_object_mut().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy resolved Worker manifest snapshot must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let legacy_memory = root.remove("memory");
|
||||||
|
let feature = root
|
||||||
|
.entry("feature")
|
||||||
|
.or_insert_with(|| serde_json::json!({}))
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy resolved Worker manifest feature must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
|
||||||
|
feature
|
||||||
|
.remove("memory")
|
||||||
|
.unwrap_or_else(|| serde_json::json!({})),
|
||||||
|
)?;
|
||||||
|
let enabled = legacy_feature_memory.enabled;
|
||||||
|
let staging_tools = legacy_feature_memory.staging;
|
||||||
|
|
||||||
|
let legacy_memory: LegacyMemoryConfig =
|
||||||
|
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
|
||||||
|
let mut workspace_settings = match (
|
||||||
|
legacy_memory.workspace_id,
|
||||||
|
legacy_memory.settings_revision,
|
||||||
|
legacy_memory.language,
|
||||||
|
) {
|
||||||
|
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"settings_revision": settings_revision,
|
||||||
|
"language": language,
|
||||||
|
})),
|
||||||
|
(None, None, None) => None,
|
||||||
|
_ => {
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
workspace_settings = None;
|
||||||
|
}
|
||||||
|
let extraction_enabled = legacy_memory.extract_threshold.is_some();
|
||||||
|
if legacy_memory.consolidation_model.is_some() {
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let consolidation_enabled = match (
|
||||||
|
legacy_memory.consolidation_threshold_files,
|
||||||
|
legacy_memory.consolidation_threshold_bytes,
|
||||||
|
) {
|
||||||
|
(None, None) => false,
|
||||||
|
(Some(5), Some(50_000)) => true,
|
||||||
|
_ => {
|
||||||
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut resolved = serde_json::json!({
|
||||||
|
"profile": {
|
||||||
|
"enabled": enabled,
|
||||||
|
"staging_tools": staging_tools,
|
||||||
|
"resident": {
|
||||||
|
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
|
||||||
|
},
|
||||||
|
"extraction": {
|
||||||
|
"enabled": extraction_enabled,
|
||||||
|
"model": serde_json::to_value(legacy_memory.extract_model)?,
|
||||||
|
"threshold": legacy_memory.extract_threshold,
|
||||||
|
"worker_max_turns": legacy_memory.extract_worker_max_turns,
|
||||||
|
},
|
||||||
|
"consolidation": {
|
||||||
|
"request_enabled": consolidation_enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if let Some(workspace_settings) = workspace_settings {
|
||||||
|
resolved
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("resolved Memory config is an object")
|
||||||
|
.insert("workspace_settings".to_string(), workspace_settings);
|
||||||
|
}
|
||||||
|
feature.insert("memory".to_string(), resolved);
|
||||||
|
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1246,36 +1467,182 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_memory_is_none() {
|
fn omitted_memory_feature_is_disabled() {
|
||||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert!(manifest.memory.is_none());
|
assert!(!manifest.feature.memory.profile.enabled);
|
||||||
|
assert!(manifest.feature.memory.workspace_settings.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_memory_section_enables_with_default_root() {
|
fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
let toml = format!(
|
||||||
|
"{MINIMAL_REQUIRED}\n\
|
||||||
|
[feature.memory.profile]\n\
|
||||||
|
enabled = true\n\
|
||||||
|
staging_tools = false\n\n\
|
||||||
|
[feature.memory.profile.resident]\n\
|
||||||
|
inject_summary = false\n\n\
|
||||||
|
[feature.memory.profile.extraction]\n\
|
||||||
|
enabled = true\n\
|
||||||
|
threshold = 42000\n\
|
||||||
|
worker_max_turns = 2\n\n\
|
||||||
|
[feature.memory.workspace_settings]\n\
|
||||||
|
workspace_id = \"workspace-1\"\n\
|
||||||
|
settings_revision = 7\n\
|
||||||
|
language = \"日本語\"\n"
|
||||||
|
);
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let mem = manifest.memory.expect("memory section parsed");
|
assert!(manifest.feature.memory.profile.enabled);
|
||||||
assert!(mem.workspace_root.is_none());
|
assert!(!manifest.feature.memory.profile.resident.inject_summary);
|
||||||
assert_eq!(mem.inject_summary, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_section_with_inject_summary_false() {
|
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
|
||||||
let mem = manifest.memory.unwrap();
|
|
||||||
assert_eq!(mem.inject_summary, Some(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_section_with_explicit_root() {
|
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
|
||||||
let mem = manifest.memory.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mem.workspace_root.unwrap(),
|
manifest.feature.memory.profile.extraction.threshold,
|
||||||
std::path::PathBuf::from("/some/where")
|
Some(42_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.workspace_settings()
|
||||||
|
.unwrap()
|
||||||
|
.language,
|
||||||
|
"日本語"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_memory_execution_validation_fails_closed() {
|
||||||
|
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
settings_revision: 1,
|
||||||
|
language: "English".to_string(),
|
||||||
|
};
|
||||||
|
let mut enabled = ResolvedMemoryFeatureConfig::default();
|
||||||
|
enabled.profile.enabled = true;
|
||||||
|
assert!(enabled.validate_execution().is_err());
|
||||||
|
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
|
||||||
|
assert!(enabled.validate_execution().is_ok());
|
||||||
|
|
||||||
|
let mut disabled = ResolvedMemoryFeatureConfig::default();
|
||||||
|
disabled.workspace_settings = Some(snapshot.clone());
|
||||||
|
assert!(disabled.validate_execution().is_err());
|
||||||
|
assert!(disabled.bind_workspace_settings(snapshot).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
||||||
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||||
|
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
|
||||||
|
let mut manifest =
|
||||||
|
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||||
|
manifest["feature"]["memory"] = serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"staging": true,
|
||||||
|
});
|
||||||
|
manifest["memory"] = serde_json::json!({
|
||||||
|
"workspace_root": "/discarded",
|
||||||
|
"query_result_limit": 999,
|
||||||
|
"inject_summary": false,
|
||||||
|
"workspace_id": "workspace-1",
|
||||||
|
"settings_revision": 9,
|
||||||
|
"language": "Français",
|
||||||
|
"extract_threshold": 1234,
|
||||||
|
"extract_worker_max_turns": 3,
|
||||||
|
"consolidation_threshold_files": 5,
|
||||||
|
"consolidation_threshold_bytes": 50000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
|
||||||
|
assert!(migrated.feature.memory.profile.enabled);
|
||||||
|
assert!(migrated.feature.memory.profile.staging_tools);
|
||||||
|
assert!(!migrated.feature.memory.profile.resident.inject_summary);
|
||||||
|
assert_eq!(
|
||||||
|
migrated.feature.memory.profile.extraction.threshold,
|
||||||
|
Some(1234)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
migrated
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.profile
|
||||||
|
.consolidation
|
||||||
|
.request_enabled
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
migrated
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.workspace_settings()
|
||||||
|
.unwrap()
|
||||||
|
.language,
|
||||||
|
"Français"
|
||||||
|
);
|
||||||
|
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
||||||
|
assert_eq!(current["schema_version"], 2);
|
||||||
|
assert!(current["manifest"].get("memory").is_none());
|
||||||
|
|
||||||
|
let mut disabled =
|
||||||
|
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||||
|
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
|
||||||
|
disabled["memory"] = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-1",
|
||||||
|
"settings_revision": 9,
|
||||||
|
"language": "Français",
|
||||||
|
});
|
||||||
|
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
|
||||||
|
assert!(!disabled.feature.memory.profile.enabled);
|
||||||
|
assert!(disabled.feature.memory.workspace_settings.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
|
||||||
|
let manifest =
|
||||||
|
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||||
|
let mut mixed = manifest.clone();
|
||||||
|
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
|
||||||
|
mixed["memory"] = serde_json::json!({});
|
||||||
|
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
|
||||||
|
|
||||||
|
let mut custom_policy =
|
||||||
|
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||||
|
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
|
||||||
|
custom_policy["memory"] = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-1",
|
||||||
|
"settings_revision": 1,
|
||||||
|
"language": "English",
|
||||||
|
"consolidation_threshold_files": 99,
|
||||||
|
"consolidation_threshold_bytes": 50000,
|
||||||
|
});
|
||||||
|
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
|
||||||
|
|
||||||
|
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
|
let mut current = write_persisted_worker_manifest_snapshot(¤t).unwrap();
|
||||||
|
current["manifest"]["memory"] = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-1",
|
||||||
|
"settings_revision": 1,
|
||||||
|
"language": "English",
|
||||||
|
});
|
||||||
|
assert!(read_persisted_worker_manifest_snapshot(current).is_err());
|
||||||
|
|
||||||
|
let mut missing_settings = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
|
missing_settings.feature.memory.profile.enabled = true;
|
||||||
|
let missing_settings = write_persisted_worker_manifest_snapshot(&missing_settings).unwrap();
|
||||||
|
assert!(read_persisted_worker_manifest_snapshot(missing_settings).is_err());
|
||||||
|
|
||||||
|
let mut malformed_legacy = manifest.clone();
|
||||||
|
malformed_legacy["feature"]["memory"] = serde_json::json!({ "enabled": "yes" });
|
||||||
|
malformed_legacy["memory"] = serde_json::json!({ "unknown": true });
|
||||||
|
assert!(read_persisted_worker_manifest_snapshot(malformed_legacy).is_err());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||||
|
"schema_version": 3,
|
||||||
|
"manifest": manifest,
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1291,14 +1658,6 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_section_with_language() {
|
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
|
||||||
let mem = manifest.memory.unwrap();
|
|
||||||
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_unknown_scheme() {
|
fn reject_unknown_scheme() {
|
||||||
let toml =
|
let toml =
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ use crate::config::{
|
|||||||
use crate::model::{AuthRef, ModelManifest};
|
use crate::model::{AuthRef, ModelManifest};
|
||||||
use crate::plugin::PluginConfig;
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
||||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
||||||
WorkerMetaConfig, paths,
|
paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||||
@@ -185,7 +185,7 @@ pub fn validate_profile_execution_target(
|
|||||||
if feature.manage_workdir.enabled {
|
if feature.manage_workdir.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||||
}
|
}
|
||||||
if feature.memory.enabled || feature.memory.staging {
|
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||||
}
|
}
|
||||||
if feature.merge_request.show
|
if feature.merge_request.show
|
||||||
@@ -642,7 +642,6 @@ fn resolve_profile_value(
|
|||||||
mcp: profile.mcp,
|
mcp: profile.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: profile.web,
|
web: profile.web,
|
||||||
memory: profile.memory.map(Into::into),
|
|
||||||
skills: profile.skills,
|
skills: profile.skills,
|
||||||
};
|
};
|
||||||
let config =
|
let config =
|
||||||
@@ -663,51 +662,6 @@ fn resolve_profile_value(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct ProfileMemoryConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
workspace_root: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
query_result_limit: Option<usize>,
|
|
||||||
#[serde(default)]
|
|
||||||
query_excerpt_lines: Option<usize>,
|
|
||||||
#[serde(default)]
|
|
||||||
inject_summary: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
extract_model: Option<ModelManifest>,
|
|
||||||
#[serde(default)]
|
|
||||||
extract_threshold: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
extract_worker_max_turns: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
consolidation_model: Option<ModelManifest>,
|
|
||||||
#[serde(default)]
|
|
||||||
consolidation_threshold_files: Option<usize>,
|
|
||||||
#[serde(default)]
|
|
||||||
consolidation_threshold_bytes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ProfileMemoryConfig> for MemoryConfig {
|
|
||||||
fn from(profile: ProfileMemoryConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
workspace_root: profile.workspace_root,
|
|
||||||
query_result_limit: profile.query_result_limit,
|
|
||||||
query_excerpt_lines: profile.query_excerpt_lines,
|
|
||||||
inject_summary: profile.inject_summary,
|
|
||||||
workspace_id: None,
|
|
||||||
settings_revision: None,
|
|
||||||
language: None,
|
|
||||||
extract_model: profile.extract_model,
|
|
||||||
extract_threshold: profile.extract_threshold,
|
|
||||||
extract_worker_max_turns: profile.extract_worker_max_turns,
|
|
||||||
consolidation_model: profile.consolidation_model,
|
|
||||||
consolidation_threshold_files: profile.consolidation_threshold_files,
|
|
||||||
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct ProfileConfig {
|
struct ProfileConfig {
|
||||||
@@ -738,8 +692,6 @@ struct ProfileConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
web: Option<WebConfig>,
|
web: Option<WebConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
memory: Option<ProfileMemoryConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
skills: Option<SkillsConfig>,
|
skills: Option<SkillsConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,12 +892,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
|
|||||||
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
||||||
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
|
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
|
||||||
}
|
}
|
||||||
if let Some(memory) = &profile.memory
|
|
||||||
&& let Some(root) = &memory.workspace_root
|
|
||||||
&& root.is_absolute()
|
|
||||||
{
|
|
||||||
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
|
|
||||||
}
|
|
||||||
if let Some(skills) = &profile.skills {
|
if let Some(skills) = &profile.skills {
|
||||||
for dir in &skills.directories {
|
for dir in &skills.directories {
|
||||||
if dir.is_absolute() {
|
if dir.is_absolute() {
|
||||||
@@ -1299,7 +1245,9 @@ mod tests {
|
|||||||
("settings_revision", serde_json::json!(2)),
|
("settings_revision", serde_json::json!(2)),
|
||||||
("language", serde_json::json!("Japanese")),
|
("language", serde_json::json!("Japanese")),
|
||||||
] {
|
] {
|
||||||
let artifact = serde_json::json!({ "memory": { (field): value } });
|
let artifact = serde_json::json!({
|
||||||
|
"feature": { "memory": { (field): value } }
|
||||||
|
});
|
||||||
let error = resolve_profile_artifact_value(
|
let error = resolve_profile_artifact_value(
|
||||||
artifact,
|
artifact,
|
||||||
ProfileSource::Registry {
|
ProfileSource::Registry {
|
||||||
@@ -1351,7 +1299,7 @@ mod tests {
|
|||||||
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
|
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
|
||||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
||||||
}));
|
}));
|
||||||
assert!(!resolved.manifest.feature.memory.enabled);
|
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
||||||
assert!(!resolved.manifest.feature.ticket.enabled);
|
assert!(!resolved.manifest.feature.ticket.enabled);
|
||||||
assert!(!resolved.manifest.feature.objective.enabled);
|
assert!(!resolved.manifest.feature.objective.enabled);
|
||||||
assert!(!resolved.manifest.feature.flow.enabled);
|
assert!(!resolved.manifest.feature.flow.enabled);
|
||||||
@@ -1630,7 +1578,7 @@ enabled = false
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||||
assert!(resolved.manifest.feature.task.enabled);
|
assert!(resolved.manifest.feature.task.enabled);
|
||||||
assert!(!resolved.manifest.feature.memory.enabled);
|
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
||||||
assert!(resolved.manifest.feature.web.enabled);
|
assert!(resolved.manifest.feature.web.enabled);
|
||||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
assert!(resolved.manifest.feature.ticket.enabled);
|
assert!(resolved.manifest.feature.ticket.enabled);
|
||||||
|
|||||||
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct MemoryConsolidateStagingOperation {
|
pub struct MemoryConsolidateStagingOperation {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub threshold_files: Option<usize>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub threshold_bytes: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -450,10 +447,21 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::extract::{CandidateKind, ExtractedCandidate};
|
use crate::extract::{CandidateKind, ExtractedCandidate};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consolidation_operation_rejects_caller_owned_thresholds() {
|
||||||
|
let error =
|
||||||
|
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
|
||||||
|
"force": false,
|
||||||
|
"threshold_files": 1,
|
||||||
|
}))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("threshold_files"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path());
|
let layout = WorkspaceLayout::resolve(temp.path());
|
||||||
let source = SourceRef {
|
let source = SourceRef {
|
||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
range: [0, 1],
|
range: [0, 1],
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ pub struct StagingEntry {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub record: StagingRecord,
|
pub record: StagingRecord,
|
||||||
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`)
|
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
|
||||||
/// に使う。
|
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
|
|||||||
Self { root: root.into() }
|
Self { root: root.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a layout from a `MemoryConfig`.
|
/// Resolve a layout from the nearest Memory marker.
|
||||||
///
|
///
|
||||||
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
/// Resolution searches `default_root` and its ancestors for the nearest
|
||||||
/// explicit root, resolution searches `default_root` and its ancestors for
|
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
|
||||||
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
/// policy directly; resolved Worker Manifests do not carry storage paths.
|
||||||
/// contain `.yoi` project records such as tickets from
|
pub fn resolve(default_root: &Path) -> Self {
|
||||||
/// becoming independent memory roots merely because they contain `.yoi`.
|
|
||||||
///
|
|
||||||
/// If no memory marker exists, this falls back to `default_root` because
|
|
||||||
/// existing call sites require a concrete layout. That fallback is a
|
|
||||||
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
|
|
||||||
/// must not be used as evidence that `.yoi` alone enables repo-local
|
|
||||||
/// memory.
|
|
||||||
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
|
|
||||||
if let Some(root) = &cfg.workspace_root {
|
|
||||||
return Self::new(root.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let root =
|
let root =
|
||||||
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
||||||
Self::new(root)
|
Self::new(root)
|
||||||
@@ -335,16 +323,6 @@ mod tests {
|
|||||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_uses_workspace_root_when_set() {
|
|
||||||
let cfg = manifest::MemoryConfig {
|
|
||||||
workspace_root: Some(PathBuf::from("/explicit")),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
|
|
||||||
assert_eq!(layout.root(), Path::new("/explicit"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -353,8 +331,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||||
std::fs::create_dir_all(&child).unwrap();
|
std::fs::create_dir_all(&child).unwrap();
|
||||||
|
|
||||||
let cfg = manifest::MemoryConfig::default();
|
let layout = WorkspaceLayout::resolve(&child);
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
|
||||||
assert_eq!(layout.root(), workspace.as_path());
|
assert_eq!(layout.root(), workspace.as_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,8 +343,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||||
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
||||||
|
|
||||||
let cfg = manifest::MemoryConfig::default();
|
let layout = WorkspaceLayout::resolve(&child);
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
|
||||||
assert_eq!(layout.root(), workspace.as_path());
|
assert_eq!(layout.root(), workspace.as_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,8 +357,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(find_memory_marker_root(&child), None);
|
assert_eq!(find_memory_marker_root(&child), None);
|
||||||
|
|
||||||
let cfg = manifest::MemoryConfig::default();
|
let layout = WorkspaceLayout::resolve(&child);
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
|
||||||
assert_eq!(layout.root(), child.as_path());
|
assert_eq!(layout.root(), child.as_path());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ mod common;
|
|||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::interceptor::{Interceptor, TurnEndAction};
|
use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::llm_client::types::{Item, RequestConfig};
|
use agen::llm_client::types::{Item, RequestConfig};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
@@ -100,8 +100,11 @@ struct PausePolicy;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for PausePolicy {
|
impl Interceptor for PausePolicy {
|
||||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
async fn on_assistant_turn_end(
|
||||||
TurnEndAction::Pause
|
&self,
|
||||||
|
_context: AssistantTurnEndContext<'_>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
Ok(TurnEndAction::Pause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +353,8 @@ async fn session_run_with_tool_call() {
|
|||||||
async fn session_resume_after_pause() {
|
async fn session_resume_after_pause() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
|
|
||||||
// First run: tool call with pause policy → Paused
|
// First terminal assistant response requests a tool; the assistant-turn
|
||||||
|
// interceptor pauses before the Engine enters the tool phase.
|
||||||
let client = MockLlmClient::with_responses(tool_call_events());
|
let client = MockLlmClient::with_responses(tool_call_events());
|
||||||
let mut worker = TestWorker::new(Engine::new(client));
|
let mut worker = TestWorker::new(Engine::new(client));
|
||||||
worker.register_tool(weather_tool_definition());
|
worker.register_tool(weather_tool_definition());
|
||||||
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
|
|||||||
// Restore state and verify
|
// Restore state and verify
|
||||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||||
assert!(state.last_run_interrupted);
|
assert!(state.last_run_interrupted);
|
||||||
assert_eq!(state.active_run_turn_count, Some(2));
|
assert_eq!(state.active_run_turn_count, Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
|
|||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
enabled = true
|
enabled = false
|
||||||
|
|
||||||
[feature.web]
|
[feature.web]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -241,11 +241,6 @@ enabled = true
|
|||||||
authoring = true
|
authoring = true
|
||||||
thread = true
|
thread = true
|
||||||
|
|
||||||
[memory]
|
|
||||||
extract_threshold = 50000
|
|
||||||
consolidation_threshold_files = 5
|
|
||||||
consolidation_threshold_bytes = 50000
|
|
||||||
|
|
||||||
[web]
|
[web]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -759,8 +759,8 @@ fn migrate_worker_aggregate_document(
|
|||||||
.get_mut("resolved_manifest_snapshot")
|
.get_mut("resolved_manifest_snapshot")
|
||||||
.filter(|snapshot| !snapshot.is_null())
|
.filter(|snapshot| !snapshot.is_null())
|
||||||
{
|
{
|
||||||
let manifest: manifest::WorkerManifest =
|
let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
|
||||||
serde_json::from_value(snapshot.clone()).map_err(|error| {
|
.map_err(|error| {
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
metadata_path,
|
metadata_path,
|
||||||
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
|
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
|
||||||
@@ -775,20 +775,14 @@ fn migrate_worker_aggregate_document(
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
snapshot
|
manifest.worker.name = expected_name.clone();
|
||||||
.as_object_mut()
|
*snapshot =
|
||||||
.and_then(|manifest| manifest.get_mut("worker"))
|
manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
|
||||||
.and_then(serde_json::Value::as_object_mut)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
metadata_path,
|
metadata_path,
|
||||||
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
|
format!("encode migrated Worker aggregate resolved manifest: {error}"),
|
||||||
)
|
)
|
||||||
})?
|
})?;
|
||||||
.insert(
|
|
||||||
"name".to_string(),
|
|
||||||
serde_json::Value::String(expected_name.clone()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
metadata.insert(
|
metadata.insert(
|
||||||
"worker_name".to_string(),
|
"worker_name".to_string(),
|
||||||
@@ -809,8 +803,8 @@ fn migrate_worker_aggregate_document(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
|
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
|
||||||
let manifest: manifest::WorkerManifest =
|
let manifest =
|
||||||
serde_json::from_value(snapshot).map_err(|error| {
|
manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
|
||||||
runtime_store_corrupt(
|
runtime_store_corrupt(
|
||||||
metadata_path,
|
metadata_path,
|
||||||
format!("decode migrated Worker aggregate resolved manifest: {error}"),
|
format!("decode migrated Worker aggregate resolved manifest: {error}"),
|
||||||
|
|||||||
@@ -746,9 +746,15 @@ fn bind_workspace_memory_settings(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
manifest
|
manifest
|
||||||
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.get_or_insert_with(manifest::MemoryConfig::default)
|
.bind_workspace_settings(snapshot.clone())
|
||||||
.bind_workspace_settings(snapshot);
|
.map_err(str::to_string)?;
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(str::to_string)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,10 +765,18 @@ fn validate_worker_memory_settings(
|
|||||||
let Some(expected) = request.memory_settings.as_ref() else {
|
let Some(expected) = request.memory_settings.as_ref() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let actual = manifest
|
manifest
|
||||||
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.as_ref()
|
.validate_execution()
|
||||||
.and_then(manifest::MemoryConfig::workspace_settings)
|
.map_err(str::to_string)?;
|
||||||
|
if !manifest.feature.memory.profile.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let actual = manifest
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.workspace_settings()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||||
})?;
|
})?;
|
||||||
@@ -3165,7 +3179,7 @@ mod tests {
|
|||||||
Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
||||||
session_id,
|
session_id,
|
||||||
)),
|
)),
|
||||||
Some(serde_json::to_value(&manifest).unwrap()),
|
Some(manifest::write_persisted_worker_manifest_snapshot(&manifest).unwrap()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
use agen::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
|
use agen::interceptor::{
|
||||||
|
Interceptor, InterceptorResult, PreLlmRequestContext, PreRequestAction, PreToolAction,
|
||||||
|
ToolCallInfo,
|
||||||
|
};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -397,15 +400,19 @@ impl CompactWorkerInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for CompactWorkerInterceptor {
|
impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
|
||||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(
|
||||||
|
&self,
|
||||||
|
context: PreLlmRequestContext<'_, A>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
let context = context.items;
|
||||||
let records = self.usage_tracker.records();
|
let records = self.usage_tracker.records();
|
||||||
let estimate = agen::token_counter::total_tokens(context, &records);
|
let estimate = agen::token_counter::total_tokens(context, &records);
|
||||||
if estimate.tokens > self.max_input_tokens {
|
if estimate.tokens > self.max_input_tokens {
|
||||||
return PreRequestAction::Cancel(format!(
|
return Ok(PreRequestAction::Cancel(format!(
|
||||||
"compact worker input occupancy exceeded {} tokens",
|
"compact worker input occupancy exceeded {} tokens",
|
||||||
self.max_input_tokens
|
self.max_input_tokens
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
|
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
|
||||||
@@ -413,25 +420,28 @@ impl Interceptor for CompactWorkerInterceptor {
|
|||||||
.store(remaining, Ordering::Release);
|
.store(remaining, Ordering::Release);
|
||||||
if let Some(item) = self.maybe_emit_warning(remaining) {
|
if let Some(item) = self.maybe_emit_warning(remaining) {
|
||||||
self.usage_tracker.note_request(context.len() + 1);
|
self.usage_tracker.note_request(context.len() + 1);
|
||||||
return PreRequestAction::ContinueWith(vec![item]);
|
return Ok(PreRequestAction::ContinueWith(vec![item]));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.usage_tracker.note_request(context.len());
|
self.usage_tracker.note_request(context.len());
|
||||||
PreRequestAction::Continue
|
Ok(PreRequestAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, A>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
|
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
|
||||||
return PreToolAction::Continue;
|
return Ok(PreToolAction::Continue);
|
||||||
}
|
}
|
||||||
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
|
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
|
||||||
if remaining > self.final_reserve_tokens {
|
if remaining > self.final_reserve_tokens {
|
||||||
return PreToolAction::Continue;
|
return Ok(PreToolAction::Continue);
|
||||||
}
|
}
|
||||||
PreToolAction::SyntheticResult(ToolResult::error(
|
Ok(PreToolAction::SyntheticResult(ToolResult::error(
|
||||||
info.call.id.clone(),
|
info.call.id.clone(),
|
||||||
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
|
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,13 +477,27 @@ mod tests {
|
|||||||
let mut context = vec![Item::user_message("hello")];
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
));
|
));
|
||||||
tracker.record_usage(&make_usage(100));
|
tracker.record_usage(&make_usage(100));
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
));
|
));
|
||||||
tracker.record_usage(&make_usage(100));
|
tracker.record_usage(&make_usage(100));
|
||||||
@@ -481,7 +505,14 @@ mod tests {
|
|||||||
// Two 100-token requests would exceed a cumulative 150-token cap, but
|
// Two 100-token requests would exceed a cumulative 150-token cap, but
|
||||||
// current occupancy is still the latest 100-token measurement.
|
// current occupancy is still the latest 100-token measurement.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -503,13 +534,27 @@ mod tests {
|
|||||||
let mut context = vec![Item::user_message("hello")];
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
));
|
));
|
||||||
tracker.record_usage(&make_usage(100));
|
tracker.record_usage(&make_usage(100));
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::ContinueWith(items)
|
PreRequestAction::ContinueWith(items)
|
||||||
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
|
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
|
||||||
));
|
));
|
||||||
@@ -523,13 +568,27 @@ mod tests {
|
|||||||
let mut context = vec![Item::user_message("hello")];
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
));
|
));
|
||||||
tracker.record_usage(&make_usage(100));
|
tracker.record_usage(&make_usage(100));
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
interceptor.pre_llm_request(&mut context).await,
|
interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut context,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
PreRequestAction::Cancel(message) if message.contains("occupancy")
|
PreRequestAction::Cancel(message) if message.contains("occupancy")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ async fn finish_controller_run<C, St>(
|
|||||||
{
|
{
|
||||||
// history / user_segments are no longer mirrored on WorkerSharedState —
|
// history / user_segments are no longer mirrored on WorkerSharedState —
|
||||||
// clients reconstruct them from `Event::Snapshot` + live
|
// clients reconstruct them from `Event::Snapshot` + live
|
||||||
// `Event::Entry` deliveries driven by the session-log sink. We
|
// `Event::Entry` deliveries driven by the session-log sink. The
|
||||||
// flip the status and kick post-run memory jobs here.
|
// lifecycle hook/task registry observes the terminal commit separately.
|
||||||
//
|
//
|
||||||
// In-flight blocks are run-local streaming state, not durable transcript.
|
// In-flight blocks are run-local streaming state, not durable transcript.
|
||||||
// Any block not cleared by a committed AssistantItem must be discarded at
|
// Any block not cleared by a committed AssistantItem must be discarded at
|
||||||
@@ -206,7 +206,6 @@ async fn finish_controller_run<C, St>(
|
|||||||
// partial text/tool arguments after newer entries.
|
// partial text/tool arguments after newer entries.
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
||||||
worker.spawn_post_run_memory_jobs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pending turn launch staged by an event handler for the next outer-loop
|
/// Pending turn launch staged by an event handler for the next outer-loop
|
||||||
@@ -938,7 +937,6 @@ where
|
|||||||
let local_filesystem = worker.local_working_directory().cloned();
|
let local_filesystem = worker.local_working_directory().cloned();
|
||||||
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
||||||
let task_feature = worker.task_feature();
|
let task_feature = worker.task_feature();
|
||||||
let memory_config = worker.manifest().memory.clone();
|
|
||||||
let web_config = worker.manifest().web.clone();
|
let web_config = worker.manifest().web.clone();
|
||||||
let mcp_config = worker.manifest().mcp.clone();
|
let mcp_config = worker.manifest().mcp.clone();
|
||||||
let spawner_name = worker.manifest().worker.name.clone();
|
let spawner_name = worker.manifest().worker.name.clone();
|
||||||
@@ -995,6 +993,41 @@ where
|
|||||||
let worker_enabled = feature_config.worker.enabled;
|
let worker_enabled = feature_config.worker.enabled;
|
||||||
let sub_worker_enabled = feature_config.sub_worker.enabled;
|
let sub_worker_enabled = feature_config.sub_worker.enabled;
|
||||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||||
|
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||||
|
worker.manifest(),
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
worker.prompts().load_full(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||||
|
(
|
||||||
|
plan.resident_summary.clone(),
|
||||||
|
plan.system_prompt_override.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let memory_lifecycle_config = memory_install_plan
|
||||||
|
.as_ref()
|
||||||
|
.map(|plan| plan.resolved_config.clone());
|
||||||
|
if let Some(plan) = memory_install_plan {
|
||||||
|
feature_registry.add_module(plan.module);
|
||||||
|
}
|
||||||
|
if let Some(memory_config) = memory_lifecycle_config
|
||||||
|
&& let Some(memory_lifecycle) =
|
||||||
|
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||||
|
worker.manifest_lifecycle_features_enabled(),
|
||||||
|
memory_config,
|
||||||
|
worker.committed_session_capture_handle(),
|
||||||
|
worker.session_extension_handle(),
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
spawner_manifest.clone(),
|
||||||
|
worker.llm_client_handle(),
|
||||||
|
prompts.clone(),
|
||||||
|
spawner_workspace_context.clone(),
|
||||||
|
worker.working_event_sender(),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
feature_registry.add_module(memory_lifecycle);
|
||||||
|
}
|
||||||
if sub_worker_enabled && !worker_enabled {
|
if sub_worker_enabled && !worker_enabled {
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
@@ -1148,38 +1181,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory tools require explicit feature exposure. Workspace memory access
|
|
||||||
// is authority-bound to the Backend Workspace API; the Worker must not
|
|
||||||
// register local filesystem memory tools even when it has local cwd/root
|
|
||||||
// authority for shell/file tools.
|
|
||||||
if feature_config.memory.enabled {
|
|
||||||
let _mem = memory_config.as_ref().ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if workspace_client.is_available() && workspace_client.workspace_id().is_some() {
|
|
||||||
let definitions = if feature_config.memory.staging {
|
|
||||||
crate::feature::builtin::memory::workspace_http_memory_consolidation_tools(
|
|
||||||
workspace_client.clone(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
crate::feature::builtin::memory::workspace_http_memory_tools(
|
|
||||||
workspace_client.clone(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
for definition in definitions {
|
|
||||||
engine.register_tool(definition);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"memory tools require Backend Workspace API authority",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut observation_providers: Vec<
|
let mut observation_providers: Vec<
|
||||||
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
|
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
|
||||||
> = Vec::new();
|
> = Vec::new();
|
||||||
@@ -1235,6 +1236,9 @@ where
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution {
|
||||||
|
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||||
|
}
|
||||||
if let Some(tracker) = tracker {
|
if let Some(tracker) = tracker {
|
||||||
worker.attach_tracker(tracker);
|
worker.attach_tracker(tracker);
|
||||||
}
|
}
|
||||||
@@ -1590,7 +1594,9 @@ async fn controller_loop<C, St>(
|
|||||||
&working_event_tx,
|
&working_event_tx,
|
||||||
target,
|
target,
|
||||||
expected_head_entries,
|
expected_head_entries,
|
||||||
) {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
shared_state.set_status(WorkerStatus::Idle);
|
shared_state.set_status(WorkerStatus::Idle);
|
||||||
let _ = working_event_tx.send(Event::Status {
|
let _ = working_event_tx.send(Event::Status {
|
||||||
@@ -1711,10 +1717,9 @@ async fn controller_loop<C, St>(
|
|||||||
tracing::warn!(%error, "Worker runtime socket cleanup failed");
|
tracing::warn!(%error, "Worker runtime socket cleanup failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background memory jobs own extract/consolidate workers after a
|
// Feature callbacks and tasks share the Worker scope. Stop them before
|
||||||
// turn completes. Join them before closing the Workdir session so no
|
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
||||||
// Worker-owned task can outlive its operation attachment.
|
worker.stop_feature_runtime("controller shutdown").await;
|
||||||
worker.wait_for_memory_jobs().await;
|
|
||||||
|
|
||||||
if let Some(session) = worker.workdir_session()
|
if let Some(session) = worker.workdir_session()
|
||||||
&& let Err(error) = session.close().await
|
&& let Err(error) = session.close().await
|
||||||
@@ -1993,7 +1998,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_rewind<C, St>(
|
async fn apply_rewind<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
working_event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
target: RewindTargetId,
|
target: RewindTargetId,
|
||||||
@@ -2003,7 +2008,7 @@ where
|
|||||||
C: LlmClient + 'static,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
match worker.rewind_to(target, expected_head_entries) {
|
match worker.rewind_to(target, expected_head_entries).await {
|
||||||
Ok(applied) => {
|
Ok(applied) => {
|
||||||
let session =
|
let session =
|
||||||
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
||||||
|
|||||||
+392
-148
@@ -23,7 +23,14 @@ use agen::tool::ToolDefinition;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::hook::{Hook, HookRegistryBuilder, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall};
|
use crate::hook::{
|
||||||
|
BeforeSessionRewrite, Hook, HookExecutionPolicy, HookRegistryBuilder, OnPromptSubmit,
|
||||||
|
OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, RunCommitted, RunExit, WorkerStopping,
|
||||||
|
};
|
||||||
|
use background::{
|
||||||
|
BackgroundTaskSpec, FeatureBackgroundTask, FeatureBackgroundTaskRegistry,
|
||||||
|
FeatureBackgroundTaskRegistryBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
/// Stable source-qualified identifier for a feature module.
|
/// Stable source-qualified identifier for a feature module.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
@@ -253,10 +260,15 @@ pub enum FeatureRuntimeKind {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum FeatureHookPoint {
|
pub enum FeatureHookPoint {
|
||||||
PreRequest,
|
PromptSubmit,
|
||||||
|
PreLlmRequest,
|
||||||
PreToolCall,
|
PreToolCall,
|
||||||
ToolResult,
|
PostToolCall,
|
||||||
TurnEnd,
|
AssistantTurnEnd,
|
||||||
|
RunExit,
|
||||||
|
RunCommitted,
|
||||||
|
BeforeSessionRewrite,
|
||||||
|
WorkerStopping,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializable declaration of a tool contribution. The executable factory is
|
/// Serializable declaration of a tool contribution. The executable factory is
|
||||||
@@ -379,16 +391,17 @@ impl FeatureInstructionContribution {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background task lifecycle phase represented by this registry slice.
|
/// Background tasks are always Worker-managed and execute inside the owning
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
/// feature scope. Report-only and detached host-managed declarations are not
|
||||||
|
/// accepted by the current contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BackgroundTaskLifecycle {
|
pub enum BackgroundTaskLifecycle {
|
||||||
DescriptorOnly,
|
WorkerManaged,
|
||||||
HostManaged,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Declaration for a feature-provided background task.
|
/// Declaration for a feature-provided executable background task.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub struct BackgroundTaskDeclaration {
|
pub struct BackgroundTaskDeclaration {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
@@ -396,11 +409,11 @@ pub struct BackgroundTaskDeclaration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BackgroundTaskDeclaration {
|
impl BackgroundTaskDeclaration {
|
||||||
pub fn descriptor_only(name: impl Into<String>, description: impl Into<String>) -> Self {
|
pub fn worker_managed(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: description.into(),
|
description: description.into(),
|
||||||
lifecycle: BackgroundTaskLifecycle::DescriptorOnly,
|
lifecycle: BackgroundTaskLifecycle::WorkerManaged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -772,6 +785,15 @@ impl FeatureInstallReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_installed_contributions(&mut self) {
|
||||||
|
self.installed = false;
|
||||||
|
self.installed_tools.clear();
|
||||||
|
self.installed_hooks.clear();
|
||||||
|
self.installed_instructions.clear();
|
||||||
|
self.declared_background_tasks.clear();
|
||||||
|
self.provided_services.clear();
|
||||||
|
}
|
||||||
|
|
||||||
fn mark_skipped(
|
fn mark_skipped(
|
||||||
&mut self,
|
&mut self,
|
||||||
kind: FeatureContributionKind,
|
kind: FeatureContributionKind,
|
||||||
@@ -881,46 +903,6 @@ fn reject_undeclared_contribution(
|
|||||||
error
|
error
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Model-visible durable notification sink skeleton. The first slice exposes
|
|
||||||
/// the boundary without implementing a new event channel.
|
|
||||||
pub struct FeatureNotificationSink<'a> {
|
|
||||||
report: &'a mut FeatureInstallReport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeatureNotificationSink<'_> {
|
|
||||||
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
|
||||||
let message = message.into();
|
|
||||||
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
|
||||||
"model notification requested during feature installation but no durable Notify host is attached: {message}"
|
|
||||||
)));
|
|
||||||
self.report.mark_skipped(
|
|
||||||
FeatureContributionKind::Notification,
|
|
||||||
"notify_model",
|
|
||||||
"durable Notify/SystemItem host is not connected during feature installation",
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transient human-facing alert sink skeleton.
|
|
||||||
pub struct FeatureAlertSink<'a> {
|
|
||||||
report: &'a mut FeatureInstallReport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeatureAlertSink<'_> {
|
|
||||||
pub fn alert(&mut self, message: impl Into<String>) {
|
|
||||||
let message = message.into();
|
|
||||||
self.report
|
|
||||||
.diagnostics
|
|
||||||
.push(FeatureDiagnostic::info(format!("feature alert: {message}")));
|
|
||||||
self.report.mark_skipped(
|
|
||||||
FeatureContributionKind::Alert,
|
|
||||||
"alert",
|
|
||||||
"transient alert host is not connected during feature installation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Diagnostic sink available to feature installers.
|
/// Diagnostic sink available to feature installers.
|
||||||
pub struct FeatureDiagnosticSink<'a> {
|
pub struct FeatureDiagnosticSink<'a> {
|
||||||
report: &'a mut FeatureInstallReport,
|
report: &'a mut FeatureInstallReport,
|
||||||
@@ -1042,15 +1024,74 @@ impl HookContributionRegistrar<'_> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, declaration: HookDeclaration) {
|
||||||
|
if !self.report.installed_hooks.contains(&declaration) {
|
||||||
|
self.report.installed_hooks.push(declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_prompt_submit(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<OnPromptSubmit> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::PromptSubmit);
|
||||||
|
self.require_declared(&declaration)?;
|
||||||
|
self.hook_builder
|
||||||
|
.add_named_on_prompt_submit(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_pre_llm_request(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<PreLlmRequest> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreLlmRequest);
|
||||||
|
self.require_declared(&declaration)?;
|
||||||
|
self.hook_builder
|
||||||
|
.add_named_pre_llm_request(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_pre_request(
|
pub fn add_pre_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
hook: impl Hook<PreLlmRequest> + 'static,
|
hook: impl Hook<PreLlmRequest> + 'static,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreRequest);
|
self.add_pre_llm_request(name, HookExecutionPolicy::fail_closed(), hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_pre_tool_call_with_policy(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<PreToolCall> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
|
||||||
self.require_declared(&declaration)?;
|
self.require_declared(&declaration)?;
|
||||||
self.hook_builder.add_pre_llm_request(hook);
|
self.hook_builder
|
||||||
self.report.installed_hooks.push(declaration);
|
.add_named_pre_tool_call(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,10 +1100,25 @@ impl HookContributionRegistrar<'_> {
|
|||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
hook: impl Hook<PreToolCall> + 'static,
|
hook: impl Hook<PreToolCall> + 'static,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
|
self.add_pre_tool_call_with_policy(name, HookExecutionPolicy::fail_closed(), hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_post_tool_call(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<PostToolCall> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::PostToolCall);
|
||||||
self.require_declared(&declaration)?;
|
self.require_declared(&declaration)?;
|
||||||
self.hook_builder.add_pre_tool_call(hook);
|
self.hook_builder
|
||||||
self.report.installed_hooks.push(declaration);
|
.add_named_post_tool_call(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,10 +1127,25 @@ impl HookContributionRegistrar<'_> {
|
|||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
hook: impl Hook<PostToolCall> + 'static,
|
hook: impl Hook<PostToolCall> + 'static,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::ToolResult);
|
self.add_post_tool_call(name, HookExecutionPolicy::fail_closed(), hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_assistant_turn_end(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<OnTurnEnd> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::AssistantTurnEnd);
|
||||||
self.require_declared(&declaration)?;
|
self.require_declared(&declaration)?;
|
||||||
self.hook_builder.add_post_tool_call(hook);
|
self.hook_builder
|
||||||
self.report.installed_hooks.push(declaration);
|
.add_named_on_turn_end(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,10 +1154,82 @@ impl HookContributionRegistrar<'_> {
|
|||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
hook: impl Hook<OnTurnEnd> + 'static,
|
hook: impl Hook<OnTurnEnd> + 'static,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::TurnEnd);
|
self.add_assistant_turn_end(name, HookExecutionPolicy::fail_closed(), hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_run_exit(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<RunExit> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunExit);
|
||||||
self.require_declared(&declaration)?;
|
self.require_declared(&declaration)?;
|
||||||
self.hook_builder.add_on_turn_end(hook);
|
self.hook_builder
|
||||||
self.report.installed_hooks.push(declaration);
|
.add_named_run_exit(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_run_committed(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<RunCommitted> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunCommitted);
|
||||||
|
self.require_declared(&declaration)?;
|
||||||
|
self.hook_builder
|
||||||
|
.add_named_run_committed(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_before_session_rewrite(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<BeforeSessionRewrite> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::BeforeSessionRewrite);
|
||||||
|
self.require_declared(&declaration)?;
|
||||||
|
self.hook_builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_worker_stopping(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<WorkerStopping> + 'static,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
let declaration = HookDeclaration::new(name, FeatureHookPoint::WorkerStopping);
|
||||||
|
self.require_declared(&declaration)?;
|
||||||
|
self.hook_builder
|
||||||
|
.add_named_worker_stopping(
|
||||||
|
format!("{}:{}", self.feature_id, declaration.name),
|
||||||
|
policy,
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
self.record(declaration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1124,33 +1267,40 @@ impl FeatureInstructionRegistrar<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background task registrar for descriptor/report-only contributions.
|
/// Registrar for executable, Worker-managed background task contributions.
|
||||||
pub struct BackgroundTaskRegistrar<'a> {
|
pub struct BackgroundTaskRegistrar<'a> {
|
||||||
feature_id: &'a FeatureId,
|
feature_id: &'a FeatureId,
|
||||||
declarations: &'a FeatureContributionDeclarations,
|
declarations: &'a FeatureContributionDeclarations,
|
||||||
|
registry: &'a mut FeatureBackgroundTaskRegistryBuilder,
|
||||||
report: &'a mut FeatureInstallReport,
|
report: &'a mut FeatureInstallReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackgroundTaskRegistrar<'_> {
|
impl BackgroundTaskRegistrar<'_> {
|
||||||
pub fn declare(
|
pub fn register(
|
||||||
&mut self,
|
&mut self,
|
||||||
declaration: BackgroundTaskDeclaration,
|
spec: BackgroundTaskSpec,
|
||||||
|
task: impl FeatureBackgroundTask + 'static,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
if !self.declarations.contains_background_task(&declaration) {
|
if !self
|
||||||
|
.declarations
|
||||||
|
.contains_background_task(&spec.declaration)
|
||||||
|
{
|
||||||
return Err(reject_undeclared_contribution(
|
return Err(reject_undeclared_contribution(
|
||||||
self.feature_id,
|
self.feature_id,
|
||||||
self.report,
|
self.report,
|
||||||
FeatureContributionKind::BackgroundTask,
|
FeatureContributionKind::BackgroundTask,
|
||||||
declaration.name,
|
spec.declaration.name,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
self.registry
|
||||||
|
.register(self.feature_id.clone(), spec.clone(), task)?;
|
||||||
if !self
|
if !self
|
||||||
.report
|
.report
|
||||||
.declared_background_tasks
|
.declared_background_tasks
|
||||||
.iter()
|
.iter()
|
||||||
.any(|task| task.name == declaration.name)
|
.any(|task| task.name == spec.declaration.name)
|
||||||
{
|
{
|
||||||
self.report.declared_background_tasks.push(declaration);
|
self.report.declared_background_tasks.push(spec.declaration);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1330,15 +1480,17 @@ impl ProtocolProviderRegistrar<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for task in background_tasks {
|
if let Some(task) = background_tasks.first() {
|
||||||
if !self
|
let reason = format!(
|
||||||
.report
|
"protocol provider background task `{}` has no executable Worker-managed handler",
|
||||||
.declared_background_tasks
|
task.name
|
||||||
.iter()
|
);
|
||||||
.any(|declared| declared.name == task.name)
|
self.report.mark_skipped(
|
||||||
{
|
FeatureContributionKind::BackgroundTask,
|
||||||
self.report.declared_background_tasks.push(task);
|
task.name.clone(),
|
||||||
}
|
reason.clone(),
|
||||||
|
);
|
||||||
|
return Err(FeatureInstallError::InvalidDescriptor(reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1352,6 +1504,7 @@ pub struct FeatureInstallContext<'a> {
|
|||||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||||
hook_builder: &'a mut HookRegistryBuilder,
|
hook_builder: &'a mut HookRegistryBuilder,
|
||||||
|
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
|
||||||
service_registry: &'a mut FeatureServiceRegistry,
|
service_registry: &'a mut FeatureServiceRegistry,
|
||||||
report: &'a mut FeatureInstallReport,
|
report: &'a mut FeatureInstallReport,
|
||||||
}
|
}
|
||||||
@@ -1392,6 +1545,7 @@ impl FeatureInstallContext<'_> {
|
|||||||
BackgroundTaskRegistrar {
|
BackgroundTaskRegistrar {
|
||||||
feature_id: self.feature_id,
|
feature_id: self.feature_id,
|
||||||
declarations: self.declarations,
|
declarations: self.declarations,
|
||||||
|
registry: self.background_task_builder,
|
||||||
report: self.report,
|
report: self.report,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1416,18 +1570,6 @@ impl FeatureInstallContext<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
|
||||||
FeatureNotificationSink {
|
|
||||||
report: self.report,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn alerts(&mut self) -> FeatureAlertSink<'_> {
|
|
||||||
FeatureAlertSink {
|
|
||||||
report: self.report,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
|
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
|
||||||
FeatureDiagnosticSink {
|
FeatureDiagnosticSink {
|
||||||
report: self.report,
|
report: self.report,
|
||||||
@@ -1440,6 +1582,7 @@ impl FeatureInstallContext<'_> {
|
|||||||
pub struct FeatureRegistryInstallReport {
|
pub struct FeatureRegistryInstallReport {
|
||||||
pub reports: Vec<FeatureInstallReport>,
|
pub reports: Vec<FeatureInstallReport>,
|
||||||
pub services: FeatureServiceRegistry,
|
pub services: FeatureServiceRegistry,
|
||||||
|
pub background_tasks: FeatureBackgroundTaskRegistry,
|
||||||
pub plan_error: Option<FeaturePlanError>,
|
pub plan_error: Option<FeaturePlanError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1795,7 +1938,7 @@ impl FeatureRegistryBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Install modules into the existing Engine tool path and hook builder.
|
/// Install modules into the existing Engine tool path and hook builder.
|
||||||
pub(crate) fn install_into_engine<C: LlmClient, A>(
|
pub(crate) fn install_into_engine<C: LlmClient, A: Send + Sync>(
|
||||||
self,
|
self,
|
||||||
worker: &mut Engine<C, Mutable, A>,
|
worker: &mut Engine<C, Mutable, A>,
|
||||||
hook_builder: &mut HookRegistryBuilder,
|
hook_builder: &mut HookRegistryBuilder,
|
||||||
@@ -1861,12 +2004,16 @@ impl FeatureRegistryBuilder {
|
|||||||
return FeatureRegistryInstallReport {
|
return FeatureRegistryInstallReport {
|
||||||
reports,
|
reports,
|
||||||
services: FeatureServiceRegistry::default(),
|
services: FeatureServiceRegistry::default(),
|
||||||
|
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||||
plan_error: Some(error),
|
plan_error: Some(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut service_registry = FeatureServiceRegistry::default();
|
let mut service_registry = FeatureServiceRegistry::default();
|
||||||
|
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||||
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||||
|
let install_hook_checkpoint = hook_builder.checkpoint();
|
||||||
|
let install_tool_checkpoint = pending_tools.len();
|
||||||
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
||||||
let ordered_modules = plan
|
let ordered_modules = plan
|
||||||
.ordered_indices
|
.ordered_indices
|
||||||
@@ -1884,6 +2031,11 @@ impl FeatureRegistryBuilder {
|
|||||||
for (module, descriptor) in ordered_modules {
|
for (module, descriptor) in ordered_modules {
|
||||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||||
let mut report = FeatureInstallReport::new(&descriptor);
|
let mut report = FeatureInstallReport::new(&descriptor);
|
||||||
|
let hook_checkpoint = hook_builder.checkpoint();
|
||||||
|
let background_checkpoint = background_task_builder.checkpoint();
|
||||||
|
let service_checkpoint = service_registry.clone();
|
||||||
|
let tool_checkpoint = pending_tools.len();
|
||||||
|
let installed_tool_checkpoint = installed_tool_names.clone();
|
||||||
|
|
||||||
let mut required_service_failed = false;
|
let mut required_service_failed = false;
|
||||||
for requirement in descriptor.requires_services.iter().cloned() {
|
for requirement in descriptor.requires_services.iter().cloned() {
|
||||||
@@ -1920,10 +2072,6 @@ impl FeatureRegistryBuilder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for background_task in descriptor.background_tasks.iter().cloned() {
|
|
||||||
report.declared_background_tasks.push(background_task);
|
|
||||||
}
|
|
||||||
|
|
||||||
let install_result = {
|
let install_result = {
|
||||||
let mut context = FeatureInstallContext {
|
let mut context = FeatureInstallContext {
|
||||||
feature_id: &descriptor.id,
|
feature_id: &descriptor.id,
|
||||||
@@ -1931,6 +2079,7 @@ impl FeatureRegistryBuilder {
|
|||||||
pending_tools,
|
pending_tools,
|
||||||
installed_tool_names: &mut installed_tool_names,
|
installed_tool_names: &mut installed_tool_names,
|
||||||
hook_builder,
|
hook_builder,
|
||||||
|
background_task_builder: &mut background_task_builder,
|
||||||
service_registry: &mut service_registry,
|
service_registry: &mut service_registry,
|
||||||
report: &mut report,
|
report: &mut report,
|
||||||
};
|
};
|
||||||
@@ -1940,18 +2089,81 @@ impl FeatureRegistryBuilder {
|
|||||||
match install_result {
|
match install_result {
|
||||||
Ok(()) => report.installed = true,
|
Ok(()) => report.installed = true,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
hook_builder.rollback_to(hook_checkpoint);
|
||||||
|
background_task_builder.rollback_to(&background_checkpoint);
|
||||||
|
service_registry = service_checkpoint.clone();
|
||||||
|
pending_tools.truncate(tool_checkpoint);
|
||||||
|
installed_tool_names = installed_tool_checkpoint.clone();
|
||||||
|
report.clear_installed_contributions();
|
||||||
report
|
report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.push(FeatureDiagnostic::error(error.to_string()));
|
.push(FeatureDiagnostic::error(error.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if report.installed {
|
||||||
|
for hook in &descriptor.hooks {
|
||||||
|
if !report.installed_hooks.contains(hook) {
|
||||||
|
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||||
|
"feature `{}` declared hook `{}` at {:?} but did not register it",
|
||||||
|
descriptor.id, hook.name, hook.point
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for task in &descriptor.background_tasks {
|
||||||
|
if !report.declared_background_tasks.contains(task) {
|
||||||
|
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||||
|
"feature `{}` declared background task `{}` but did not register an executable handler",
|
||||||
|
descriptor.id, task.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
|
{
|
||||||
|
hook_builder.rollback_to(hook_checkpoint);
|
||||||
|
background_task_builder.rollback_to(&background_checkpoint);
|
||||||
|
service_registry = service_checkpoint.clone();
|
||||||
|
pending_tools.truncate(tool_checkpoint);
|
||||||
|
installed_tool_names = installed_tool_checkpoint.clone();
|
||||||
|
report.clear_installed_contributions();
|
||||||
|
report.clear_installed_contributions();
|
||||||
|
}
|
||||||
|
}
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
FeatureRegistryInstallReport {
|
let failed = reports.iter().any(|report| {
|
||||||
reports,
|
report
|
||||||
services: service_registry,
|
.diagnostics
|
||||||
plan_error: None,
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
|
});
|
||||||
|
if failed {
|
||||||
|
hook_builder.rollback_to(install_hook_checkpoint);
|
||||||
|
pending_tools.truncate(install_tool_checkpoint);
|
||||||
|
for report in &mut reports {
|
||||||
|
if report.installed {
|
||||||
|
report.clear_installed_contributions();
|
||||||
|
report.diagnostics.push(FeatureDiagnostic::warning(
|
||||||
|
"feature scope rolled back because another contribution failed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FeatureRegistryInstallReport {
|
||||||
|
reports,
|
||||||
|
services: FeatureServiceRegistry::default(),
|
||||||
|
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||||
|
plan_error: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FeatureRegistryInstallReport {
|
||||||
|
reports,
|
||||||
|
services: service_registry,
|
||||||
|
background_tasks: background_task_builder.build(),
|
||||||
|
plan_error: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1996,9 +2208,11 @@ pub enum FeatureInstallError {
|
|||||||
Install(String),
|
Install(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod background;
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
pub(crate) mod session;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -2398,13 +2612,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_contributions_are_recorded() {
|
fn executable_contributions_are_recorded() {
|
||||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||||
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
|
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"));
|
||||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
|
||||||
"daily",
|
|
||||||
"descriptor-only background task",
|
|
||||||
));
|
|
||||||
let mut hook_builder = HookRegistryBuilder::default();
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
@@ -2420,7 +2630,7 @@ mod tests {
|
|||||||
let feature_report = &report.reports[0];
|
let feature_report = &report.reports[0];
|
||||||
assert!(feature_report.installed);
|
assert!(feature_report.installed);
|
||||||
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
||||||
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
assert!(feature_report.declared_background_tasks.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2480,8 +2690,9 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
assert_eq!(pending_tools.len(), 1);
|
assert!(pending_tools.is_empty());
|
||||||
assert!(report.reports[0].installed);
|
assert!(!report.reports[0].installed);
|
||||||
|
assert!(report.reports[0].installed_tools.is_empty());
|
||||||
assert!(!report.reports[1].installed);
|
assert!(!report.reports[1].installed);
|
||||||
assert!(
|
assert!(
|
||||||
report.reports[1]
|
report.reports[1]
|
||||||
@@ -2558,7 +2769,7 @@ mod tests {
|
|||||||
"1.0.0",
|
"1.0.0",
|
||||||
"startup-discovered service",
|
"startup-discovered service",
|
||||||
))
|
))
|
||||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||||
"provider-poller",
|
"provider-poller",
|
||||||
"provider lifecycle poller",
|
"provider lifecycle poller",
|
||||||
))
|
))
|
||||||
@@ -2568,7 +2779,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_provider_registers_startup_discovered_contributions_through_worker_path() {
|
fn protocol_provider_report_only_background_task_is_rejected_atomically() {
|
||||||
let provider = ProtocolProviderDeclaration::new(
|
let provider = ProtocolProviderDeclaration::new(
|
||||||
ProviderId::builtin("dynamic-provider"),
|
ProviderId::builtin("dynamic-provider"),
|
||||||
"test-protocol",
|
"test-protocol",
|
||||||
@@ -2599,30 +2810,18 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
let feature_report = &report.reports[0];
|
let feature_report = &report.reports[0];
|
||||||
|
|
||||||
assert!(feature_report.installed);
|
assert!(!feature_report.installed);
|
||||||
assert_eq!(feature_report.installed_tools, vec!["DynamicTool"]);
|
assert!(feature_report.installed_tools.is_empty());
|
||||||
assert_eq!(tool_names, vec!["DynamicTool"]);
|
assert!(tool_names.is_empty());
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
assert_eq!(feature_report.provided_services.len(), 1);
|
assert!(feature_report.provided_services.is_empty());
|
||||||
assert_eq!(
|
assert!(feature_report.declared_background_tasks.is_empty());
|
||||||
feature_report.provided_services[0].id,
|
|
||||||
ServiceId::builtin("dynamic-service")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
feature_report.declared_background_tasks[0].name,
|
|
||||||
"provider-poller"
|
|
||||||
);
|
|
||||||
assert_eq!(feature_report.protocol_providers.len(), 1);
|
assert_eq!(feature_report.protocol_providers.len(), 1);
|
||||||
assert_eq!(
|
assert!(feature_report.diagnostics.iter().any(|diagnostic| {
|
||||||
feature_report.protocol_providers[0].state,
|
diagnostic
|
||||||
ProtocolProviderLifecycleState::Ready
|
.message
|
||||||
);
|
.contains("has no executable Worker-managed handler")
|
||||||
assert!(
|
}));
|
||||||
feature_report
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.message.contains("startup discovery completed"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2779,8 +2978,8 @@ mod tests {
|
|||||||
async fn call(
|
async fn call(
|
||||||
&self,
|
&self,
|
||||||
_input: &crate::hook::ToolCallSummary,
|
_input: &crate::hook::ToolCallSummary,
|
||||||
) -> crate::hook::HookPreToolAction {
|
) -> Result<crate::hook::HookPreToolAction, crate::hook::HookError> {
|
||||||
crate::hook::HookPreToolAction::Continue
|
Ok(crate::hook::HookPreToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2804,6 +3003,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NoopBackgroundTask;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FeatureBackgroundTask for NoopBackgroundTask {
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
_context: background::BackgroundTaskContext,
|
||||||
|
_cancellation: background::BackgroundTaskCancellation,
|
||||||
|
) -> Result<(), crate::hook::HookError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct BackgroundFeature {
|
struct BackgroundFeature {
|
||||||
descriptor: FeatureDescriptor,
|
descriptor: FeatureDescriptor,
|
||||||
task_name: &'static str,
|
task_name: &'static str,
|
||||||
@@ -2818,12 +3030,22 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
context: &mut FeatureInstallContext<'_>,
|
context: &mut FeatureInstallContext<'_>,
|
||||||
) -> Result<(), FeatureInstallError> {
|
) -> Result<(), FeatureInstallError> {
|
||||||
context
|
let declaration = self
|
||||||
.background_tasks()
|
.descriptor
|
||||||
.declare(BackgroundTaskDeclaration::descriptor_only(
|
.background_tasks
|
||||||
self.task_name,
|
.iter()
|
||||||
"runtime background task",
|
.find(|task| task.name == self.task_name)
|
||||||
))
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
BackgroundTaskDeclaration::worker_managed(
|
||||||
|
self.task_name,
|
||||||
|
"undeclared background task",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
context.background_tasks().register(
|
||||||
|
BackgroundTaskSpec::single_flight(declaration, std::time::Duration::from_secs(1)),
|
||||||
|
NoopBackgroundTask,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2985,25 +3207,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn background_task_declaration_is_descriptor_contribution() {
|
async fn executable_background_task_is_registered_in_worker_scope() {
|
||||||
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
||||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||||
"declared-task",
|
"declared-task",
|
||||||
"descriptor contribution",
|
"descriptor contribution",
|
||||||
));
|
));
|
||||||
let mut hook_builder = HookRegistryBuilder::default();
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let report = FeatureRegistryBuilder::new()
|
let report = FeatureRegistryBuilder::new()
|
||||||
.with_module(ServiceFeature { descriptor })
|
.with_module(BackgroundFeature {
|
||||||
|
descriptor,
|
||||||
|
task_name: "declared-task",
|
||||||
|
})
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
assert!(report.reports[0].installed);
|
assert!(report.reports[0].installed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.reports[0].declared_background_tasks[0].name,
|
report.reports[0].declared_background_tasks[0].name,
|
||||||
"declared-task"
|
"declared-task"
|
||||||
);
|
);
|
||||||
assert!(report.reports[0].skipped.is_empty());
|
assert!(report.reports[0].skipped.is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
report
|
||||||
|
.background_tasks
|
||||||
|
.start(
|
||||||
|
&FeatureId::builtin("background"),
|
||||||
|
"declared-task",
|
||||||
|
crate::hook::HookInvocationContext::default(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
background::BackgroundTaskStart::Started { .. }
|
||||||
|
));
|
||||||
|
report.background_tasks.shutdown().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
report.background_tasks.diagnostics()[0].outcome,
|
||||||
|
background::BackgroundTaskOutcome::Completed
|
||||||
|
| background::BackgroundTaskOutcome::Cancelled
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3118,7 +3359,10 @@ mod tests {
|
|||||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hook_points,
|
hook_points,
|
||||||
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
vec![
|
||||||
|
FeatureHookPoint::PreLlmRequest,
|
||||||
|
FeatureHookPoint::PreToolCall
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert!(descriptor.background_tasks.is_empty());
|
assert!(descriptor.background_tasks.is_empty());
|
||||||
assert!(descriptor.provides_services.is_empty());
|
assert!(descriptor.provides_services.is_empty());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@ pub mod flow_transition;
|
|||||||
pub mod manage_workdir;
|
pub mod manage_workdir;
|
||||||
pub mod manage_worker;
|
pub mod manage_worker;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_extract;
|
pub(crate) mod memory_lifecycle;
|
||||||
|
pub mod memory_staging_output;
|
||||||
pub mod merge_request;
|
pub mod merge_request;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod orchestration;
|
pub mod orchestration;
|
||||||
@@ -19,8 +20,6 @@ pub mod ticket;
|
|||||||
pub mod worker_observation;
|
pub mod worker_observation;
|
||||||
pub mod workspace_worker_discovery;
|
pub mod workspace_worker_discovery;
|
||||||
|
|
||||||
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
|
|
||||||
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
|
|
||||||
pub use task::{TaskFeature, task_tools_feature};
|
pub use task::{TaskFeature, task_tools_feature};
|
||||||
pub use ticket::{
|
pub use ticket::{
|
||||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ use schemars::JsonSchema;
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::feature::{
|
||||||
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||||
|
ToolDeclaration,
|
||||||
|
};
|
||||||
use crate::worker::{
|
use crate::worker::{
|
||||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
};
|
};
|
||||||
@@ -338,6 +342,151 @@ fn query_schema() -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct MemoryFeatureInstallPlan {
|
||||||
|
pub module: MemoryToolsFeature,
|
||||||
|
pub resident_summary: Option<String>,
|
||||||
|
pub system_prompt_override: Option<String>,
|
||||||
|
pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryFeatureInstallPlan {
|
||||||
|
pub async fn prepare(
|
||||||
|
manifest: &manifest::WorkerManifest,
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||||
|
) -> std::io::Result<Option<Self>> {
|
||||||
|
Self::prepare_resolved(
|
||||||
|
manifest.feature.memory.clone(),
|
||||||
|
client,
|
||||||
|
prompts,
|
||||||
|
manifest.profile.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_resolved(
|
||||||
|
config: manifest::ResolvedMemoryFeatureConfig,
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||||
|
profile: Option<manifest::ProfileManifestSnapshot>,
|
||||||
|
) -> std::io::Result<Option<Self>> {
|
||||||
|
let memory_consolidation_worker = profile.as_ref().is_some_and(|snapshot| {
|
||||||
|
matches!(
|
||||||
|
&snapshot.source,
|
||||||
|
manifest::ProfileSource::Registry {
|
||||||
|
source: manifest::ProfileRegistrySource::Builtin,
|
||||||
|
name,
|
||||||
|
..
|
||||||
|
} if name == "memory-consolidation"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
config
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||||
|
if !config.profile.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let workspace_id = client.workspace_id().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"Memory tools require Backend Workspace API authority",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let settings = config
|
||||||
|
.workspace_settings()
|
||||||
|
.expect("validated enabled Memory config has Workspace settings");
|
||||||
|
if settings.workspace_id != workspace_id {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"Memory settings belong to {} instead of {}",
|
||||||
|
settings.workspace_id, workspace_id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resident_summary = if config.profile.resident.inject_summary {
|
||||||
|
match client
|
||||||
|
.execute_memory_backend_operation(
|
||||||
|
memory::backend::MemoryBackendOperation::ResidentSummary(
|
||||||
|
memory::backend::MemoryResidentSummaryOperation::default(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => {
|
||||||
|
output.content
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
tracing::debug!(?other, "unexpected resident Memory Backend result");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!(%error, "resident Memory summary unavailable");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let system_prompt_override = if memory_consolidation_worker {
|
||||||
|
let language = settings.language;
|
||||||
|
Some(
|
||||||
|
prompts
|
||||||
|
.memory_consolidation_system(&language)
|
||||||
|
.map_err(|error| std::io::Error::other(error.to_string()))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
module: MemoryToolsFeature::new(client, config.profile.staging_tools),
|
||||||
|
resident_summary,
|
||||||
|
system_prompt_override,
|
||||||
|
resolved_config: config,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MemoryToolsFeature {
|
||||||
|
tools: Vec<ToolDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryToolsFeature {
|
||||||
|
pub fn new(client: Arc<dyn WorkspaceClient>, staging_tools: bool) -> Self {
|
||||||
|
let tools = if staging_tools {
|
||||||
|
workspace_http_memory_consolidation_tools(client)
|
||||||
|
} else {
|
||||||
|
workspace_http_memory_tools(client)
|
||||||
|
};
|
||||||
|
Self { tools }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for MemoryToolsFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
let mut descriptor = FeatureDescriptor::builtin("memory", "Memory")
|
||||||
|
.with_description("Workspace Memory document, query, and staging tools.");
|
||||||
|
for tool in &self.tools {
|
||||||
|
let (meta, _) = tool();
|
||||||
|
descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description));
|
||||||
|
}
|
||||||
|
descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
for tool in &self.tools {
|
||||||
|
let (meta, _) = tool();
|
||||||
|
context
|
||||||
|
.tools()
|
||||||
|
.register(ToolContribution::new(meta.name, tool.clone()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -350,6 +499,39 @@ mod tests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let content = content.to_string();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = [0_u8; 1024];
|
||||||
|
let _ = stream.read(&mut request).unwrap();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"result": {
|
||||||
|
"kind": "tool_output",
|
||||||
|
"summary": "resident Memory summary collected",
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
});
|
||||||
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
|
"workspace",
|
||||||
|
format!("http://{addr}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||||
let mut names = definitions
|
let mut names = definitions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -368,6 +550,132 @@ mod tests {
|
|||||||
.input_schema
|
.input_schema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_install_plan_is_the_fail_closed_config_boundary() {
|
||||||
|
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
||||||
|
let disabled = MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
manifest::ResolvedMemoryFeatureConfig::default(),
|
||||||
|
test_client(),
|
||||||
|
prompts.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(disabled.is_none());
|
||||||
|
|
||||||
|
let mut enabled = manifest::ResolvedMemoryFeatureConfig::default();
|
||||||
|
enabled.profile.enabled = true;
|
||||||
|
enabled.profile.resident.inject_summary = false;
|
||||||
|
assert!(
|
||||||
|
MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
enabled.clone(),
|
||||||
|
test_client(),
|
||||||
|
prompts.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
enabled
|
||||||
|
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
settings_revision: 1,
|
||||||
|
language: "English".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut foreign = enabled.clone();
|
||||||
|
foreign.workspace_settings.as_mut().unwrap().workspace_id = "other-workspace".to_string();
|
||||||
|
assert!(
|
||||||
|
MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
foreign,
|
||||||
|
test_client(),
|
||||||
|
prompts.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let plan = MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
enabled.clone(),
|
||||||
|
test_client(),
|
||||||
|
prompts.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(plan.resident_summary.is_none());
|
||||||
|
assert!(plan.system_prompt_override.is_none());
|
||||||
|
|
||||||
|
enabled.profile.resident.inject_summary = true;
|
||||||
|
let plan = MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
enabled,
|
||||||
|
resident_client("# Durable Memory"),
|
||||||
|
prompts,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(plan.resident_summary.as_deref(), Some("# Durable Memory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_prompt_contribution_rereads_resident_summary_for_each_install() {
|
||||||
|
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
||||||
|
let mut config = manifest::ResolvedMemoryFeatureConfig::default();
|
||||||
|
config.profile.enabled = true;
|
||||||
|
config
|
||||||
|
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
settings_revision: 1,
|
||||||
|
language: "English".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
config.clone(),
|
||||||
|
resident_client("first resident summary"),
|
||||||
|
prompts.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let restored = MemoryFeatureInstallPlan::prepare_resolved(
|
||||||
|
config,
|
||||||
|
resident_client("updated resident summary"),
|
||||||
|
prompts,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
first.resident_summary.as_deref(),
|
||||||
|
Some("first resident summary")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.resident_summary.as_deref(),
|
||||||
|
Some("updated resident summary")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_feature_owns_normal_and_staging_tool_surfaces() {
|
||||||
|
let normal = MemoryToolsFeature::new(test_client(), false);
|
||||||
|
let normal_names = tool_names(normal.tools);
|
||||||
|
assert!(normal_names.contains(&"MemoryQuery".to_string()));
|
||||||
|
assert!(!normal_names.contains(&"MemoryStagingList".to_string()));
|
||||||
|
|
||||||
|
let staging = MemoryToolsFeature::new(test_client(), true);
|
||||||
|
assert_eq!(staging.descriptor().id.as_str(), "builtin:memory");
|
||||||
|
let staging_names = tool_names(staging.tools);
|
||||||
|
assert!(staging_names.contains(&"MemoryQuery".to_string()));
|
||||||
|
assert!(staging_names.contains(&"MemoryStagingList".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
||||||
let names = tool_names(workspace_http_memory_tools(test_client()));
|
let names = tool_names(workspace_http_memory_tools(test_client()));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+17
-19
@@ -28,7 +28,7 @@ const FINISH_DESCRIPTION: &str =
|
|||||||
"Finish Memory extraction after validating the number of candidates staged during this run.";
|
"Finish Memory extraction after validating the number of candidates staged during this run.";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct MemoryExtractState {
|
pub(crate) struct MemoryStagingOutputState {
|
||||||
view: Arc<SessionCapture>,
|
view: Arc<SessionCapture>,
|
||||||
workspace_client: Arc<dyn WorkspaceClient>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
source: SourceRef,
|
source: SourceRef,
|
||||||
@@ -37,7 +37,7 @@ pub(crate) struct MemoryExtractState {
|
|||||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryExtractState {
|
impl MemoryStagingOutputState {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
view: SessionCapture,
|
view: SessionCapture,
|
||||||
workspace_client: Arc<dyn WorkspaceClient>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
@@ -70,22 +70,20 @@ impl MemoryExtractState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct MemoryExtractFeature {
|
pub(crate) struct MemoryStagingOutputFeature {
|
||||||
state: MemoryExtractState,
|
state: MemoryStagingOutputState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryExtractFeature {
|
impl MemoryStagingOutputFeature {
|
||||||
pub(crate) fn new(state: MemoryExtractState) -> Self {
|
pub(crate) fn new(state: MemoryStagingOutputState) -> Self {
|
||||||
Self { state }
|
Self { state }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureModule for MemoryExtractFeature {
|
impl FeatureModule for MemoryStagingOutputFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
FeatureDescriptor::builtin("memory-extract", "Memory Extract")
|
FeatureDescriptor::builtin("memory-staging-output", "Memory Staging Output")
|
||||||
.with_description(
|
.with_description("Restricted Memory staging output for an extraction Internal Worker.")
|
||||||
"Memory staging and extraction completion, independent from session exploration.",
|
|
||||||
)
|
|
||||||
.with_tool(ToolDeclaration::new(
|
.with_tool(ToolDeclaration::new(
|
||||||
"StageMemoryCandidate",
|
"StageMemoryCandidate",
|
||||||
STAGE_DESCRIPTION,
|
STAGE_DESCRIPTION,
|
||||||
@@ -109,7 +107,7 @@ impl FeatureModule for MemoryExtractFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
|
fn stage_definition(state: MemoryStagingOutputState) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
|
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
@@ -123,7 +121,7 @@ fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_definition(state: MemoryExtractState) -> ToolDefinition {
|
fn finish_definition(state: MemoryStagingOutputState) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
|
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
@@ -157,7 +155,7 @@ struct FinishMemoryExtractionParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct StageMemoryCandidateTool {
|
struct StageMemoryCandidateTool {
|
||||||
state: MemoryExtractState,
|
state: MemoryStagingOutputState,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -252,7 +250,7 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FinishMemoryExtractionTool {
|
struct FinishMemoryExtractionTool {
|
||||||
state: MemoryExtractState,
|
state: MemoryStagingOutputState,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -431,8 +429,8 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn state() -> MemoryExtractState {
|
fn state() -> MemoryStagingOutputState {
|
||||||
MemoryExtractState::new(
|
MemoryStagingOutputState::new(
|
||||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||||
crate::worker::marker_workspace_client(None, "test-backend"),
|
crate::worker::marker_workspace_client(None, "test-backend"),
|
||||||
SourceRef {
|
SourceRef {
|
||||||
@@ -445,8 +443,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_extract_declares_only_memory_mutation_tools() {
|
fn memory_extract_declares_only_memory_mutation_tools() {
|
||||||
let descriptor = MemoryExtractFeature::new(state()).descriptor();
|
let descriptor = MemoryStagingOutputFeature::new(state()).descriptor();
|
||||||
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract");
|
assert_eq!(descriptor.id.as_str(), "builtin:memory-staging-output");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
descriptor
|
descriptor
|
||||||
.tools
|
.tools
|
||||||
@@ -125,7 +125,7 @@ impl FeatureModule for TaskFeature {
|
|||||||
))
|
))
|
||||||
.with_hook(HookDeclaration::new(
|
.with_hook(HookDeclaration::new(
|
||||||
"task-reminder-pre-request",
|
"task-reminder-pre-request",
|
||||||
FeatureHookPoint::PreRequest,
|
FeatureHookPoint::PreLlmRequest,
|
||||||
))
|
))
|
||||||
.with_hook(HookDeclaration::new(
|
.with_hook(HookDeclaration::new(
|
||||||
"task-reminder-tool-usage",
|
"task-reminder-tool-usage",
|
||||||
@@ -209,24 +209,27 @@ struct TaskReminderPreRequestHook {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
||||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
let tasks = self.state.task_store.list();
|
let tasks = self.state.task_store.list();
|
||||||
if tasks.is_empty() {
|
if tasks.is_empty() {
|
||||||
return HookPreRequestAction::Continue;
|
return Ok(HookPreRequestAction::Continue);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (since_task_management, since_reminder) = self.state.reminder_state.note_request();
|
let (since_task_management, since_reminder) = self.state.reminder_state.note_request();
|
||||||
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|
||||||
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
|
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
|
||||||
{
|
{
|
||||||
return HookPreRequestAction::Continue;
|
return Ok(HookPreRequestAction::Continue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(system_items) = input.system_items() {
|
if let Some(system_items) = input.system_items() {
|
||||||
self.state.reminder_state.note_reminder();
|
self.state.reminder_state.note_reminder();
|
||||||
system_items.append_task_reminder(render_task_reminder_body(&tasks));
|
system_items.append_task_reminder(render_task_reminder_body(&tasks));
|
||||||
}
|
}
|
||||||
HookPreRequestAction::Continue
|
Ok(HookPreRequestAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,11 +239,14 @@ struct TaskReminderToolUsageHook {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreToolCall> for TaskReminderToolUsageHook {
|
impl Hook<PreToolCall> for TaskReminderToolUsageHook {
|
||||||
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &ToolCallSummary,
|
||||||
|
) -> Result<HookPreToolAction, crate::hook::HookError> {
|
||||||
if is_task_management_tool(&input.tool_name) {
|
if is_task_management_tool(&input.tool_name) {
|
||||||
self.state.reminder_state.note_task_management();
|
self.state.reminder_state.note_task_management();
|
||||||
}
|
}
|
||||||
HookPreToolAction::Continue
|
Ok(HookPreToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2115,8 +2115,7 @@ read exit_notification || true
|
|||||||
meta.name
|
meta.name
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert!(!names.iter().any(|name| name == "Mcp_demo_search_files"));
|
assert!(names.is_empty());
|
||||||
assert!(names.iter().any(|name| name == "Mcp_demo_unique"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell_tool_server(response: &str) -> McpStdioServerSpec {
|
fn shell_tool_server(response: &str) -> McpStdioServerSpec {
|
||||||
|
|||||||
@@ -7621,7 +7621,7 @@ mod tests {
|
|||||||
)))
|
)))
|
||||||
.install_into_pending(&mut pending, &mut hooks);
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
|
||||||
assert_eq!(pending.len(), 1);
|
assert!(pending.is_empty());
|
||||||
assert_eq!(skipped_count(&report), 1);
|
assert_eq!(skipped_count(&report), 1);
|
||||||
assert!(has_diagnostic(&report, "duplicate tool contribution"));
|
assert!(has_diagnostic(&report, "duplicate tool contribution"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agen::{HistoryEntry, UsageRecord};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::session_history::SessionHistoryMetadata;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CommittedRunExit {
|
||||||
|
Finished,
|
||||||
|
NonFinal,
|
||||||
|
Interrupted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable projection of one durably committed session-log location.
|
||||||
|
///
|
||||||
|
/// Feature code receives this value only after the host has committed the
|
||||||
|
/// terminal run record. The projection deliberately carries annotated history
|
||||||
|
/// rather than the public flattened transcript so provenance-sensitive
|
||||||
|
/// features can construct their own bounded views.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct CommittedSessionCapture {
|
||||||
|
pub(crate) session_id: String,
|
||||||
|
pub(crate) segment_id: String,
|
||||||
|
/// Monotonic committed-log revision for the captured Segment.
|
||||||
|
pub(crate) session_revision: u64,
|
||||||
|
pub(crate) entry_count: usize,
|
||||||
|
pub(crate) run_exit: CommittedRunExit,
|
||||||
|
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||||
|
pub(crate) usage_history: Vec<UsageRecord>,
|
||||||
|
pub(crate) extensions: Vec<(String, Value)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct CommittedSessionLocation {
|
||||||
|
pub(crate) session_id: String,
|
||||||
|
pub(crate) segment_id: String,
|
||||||
|
/// Monotonic committed-log revision for the captured Segment.
|
||||||
|
pub(crate) session_revision: u64,
|
||||||
|
pub(crate) entry_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommittedSessionCapture {
|
||||||
|
pub(crate) fn location(&self) -> CommittedSessionLocation {
|
||||||
|
CommittedSessionLocation {
|
||||||
|
session_id: self.session_id.clone(),
|
||||||
|
segment_id: self.segment_id.clone(),
|
||||||
|
session_revision: self.session_revision,
|
||||||
|
entry_count: self.entry_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub(crate) enum FeatureSessionError {
|
||||||
|
#[error("read committed session failed: {0}")]
|
||||||
|
Capture(String),
|
||||||
|
#[error("append session extension failed: {0}")]
|
||||||
|
Extension(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct CommittedSessionCaptureHandle {
|
||||||
|
capture: Arc<
|
||||||
|
dyn Fn() -> Result<CommittedSessionCapture, FeatureSessionError> + Send + Sync + 'static,
|
||||||
|
>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommittedSessionCaptureHandle {
|
||||||
|
pub(crate) fn new(
|
||||||
|
capture: impl Fn() -> Result<CommittedSessionCapture, FeatureSessionError>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
capture: Arc::new(capture),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture(&self) -> Result<CommittedSessionCapture, FeatureSessionError> {
|
||||||
|
(self.capture)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct SessionExtensionHandle {
|
||||||
|
append: Arc<
|
||||||
|
dyn Fn(&CommittedSessionLocation, &str, Value) -> Result<bool, FeatureSessionError>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ 'static,
|
||||||
|
>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionExtensionHandle {
|
||||||
|
pub(crate) fn new(
|
||||||
|
append: impl Fn(&CommittedSessionLocation, &str, Value) -> Result<bool, FeatureSessionError>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
append: Arc::new(append),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends an extension only while the committed session is still at the
|
||||||
|
/// exact location captured by the feature. `Ok(false)` is a stale-write
|
||||||
|
/// fence, not an I/O failure.
|
||||||
|
pub(crate) fn append_if_current(
|
||||||
|
&self,
|
||||||
|
expected: &CommittedSessionLocation,
|
||||||
|
domain: &str,
|
||||||
|
payload: Value,
|
||||||
|
) -> Result<bool, FeatureSessionError> {
|
||||||
|
(self.append)(expected, domain, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
+645
-52
@@ -23,8 +23,105 @@ use agen::interceptor::{
|
|||||||
};
|
};
|
||||||
use agen::tool::{ToolOutput, ToolResult};
|
use agen::tool::{ToolOutput, ToolResult};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use session_store::{SystemItem, SystemReminder};
|
use session_store::{SystemItem, SystemReminder};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::SessionEntryRef;
|
||||||
|
|
||||||
|
const HOOK_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
|
||||||
|
|
||||||
|
/// Failure category exposed by the safe Worker hook boundary.
|
||||||
|
///
|
||||||
|
/// Categories are intentionally closed and payload-free so extensions cannot
|
||||||
|
/// smuggle provider, credential, or history data into diagnostics.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HookErrorCategory {
|
||||||
|
InvalidInput,
|
||||||
|
Dependency,
|
||||||
|
Timeout,
|
||||||
|
Cancelled,
|
||||||
|
Trap,
|
||||||
|
ScopeDisposed,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded hook callback failure. Raw tool arguments, output, prompts, and
|
||||||
|
/// credentials must never be placed in `diagnostic`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
#[error("{category:?}: {diagnostic}")]
|
||||||
|
pub struct HookError {
|
||||||
|
pub category: HookErrorCategory,
|
||||||
|
pub diagnostic: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookError {
|
||||||
|
pub fn new(category: HookErrorCategory, diagnostic: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
category,
|
||||||
|
diagnostic: bounded_utf8(diagnostic.into(), HOOK_DIAGNOSTIC_MAX_BYTES),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure behavior declared when a hook is registered.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HookFailurePolicy {
|
||||||
|
/// Gate the current operation when the hook cannot decide safely.
|
||||||
|
FailClosed,
|
||||||
|
/// Keep the already-authorized operation moving and emit a diagnostic.
|
||||||
|
FailOpenWithDiagnostic,
|
||||||
|
/// Keep committed state intact and mark the failure for operator attention.
|
||||||
|
AttentionRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HOOK_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
const MAX_HOOK_TIMEOUT_MS: u64 = 120_000;
|
||||||
|
|
||||||
|
/// Host-enforced execution and failure budget for one hook registration.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct HookExecutionPolicy {
|
||||||
|
pub failure: HookFailurePolicy,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookExecutionPolicy {
|
||||||
|
pub const fn new(failure: HookFailurePolicy, timeout_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
failure,
|
||||||
|
timeout_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn fail_closed() -> Self {
|
||||||
|
Self::new(HookFailurePolicy::FailClosed, DEFAULT_HOOK_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(self) -> Result<Self, HookError> {
|
||||||
|
if self.timeout_ms == 0 || self.timeout_ms > MAX_HOOK_TIMEOUT_MS {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::InvalidInput,
|
||||||
|
format!("hook timeout_ms must be within 1..={MAX_HOOK_TIMEOUT_MS}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_utf8(mut value: String, max_bytes: usize) -> String {
|
||||||
|
if value.len() <= max_bytes {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
let mut end = max_bytes;
|
||||||
|
while end > 0 && !value.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
value.truncate(end);
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
/// Hook-facing prompt-submit action.
|
/// Hook-facing prompt-submit action.
|
||||||
///
|
///
|
||||||
@@ -285,12 +382,6 @@ pub struct TurnEndInfo {
|
|||||||
pub final_text_preview: String,
|
pub final_text_preview: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information passed to `OnAbort` hooks.
|
|
||||||
pub struct AbortInfo {
|
|
||||||
/// Reason supplied by the aborter.
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Hook Event Kinds
|
// Hook Event Kinds
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -315,10 +406,8 @@ pub struct PreLlmRequest;
|
|||||||
pub struct PreToolCall;
|
pub struct PreToolCall;
|
||||||
/// After each tool completes; observational except it may abort the run.
|
/// After each tool completes; observational except it may abort the run.
|
||||||
pub struct PostToolCall;
|
pub struct PostToolCall;
|
||||||
/// When a turn ends with no tool calls; observational except it may pause.
|
/// After every terminal assistant response is committed; observational except it may pause.
|
||||||
pub struct OnTurnEnd;
|
pub struct OnTurnEnd;
|
||||||
/// When execution is interrupted; observational only.
|
|
||||||
pub struct OnAbort;
|
|
||||||
|
|
||||||
impl HookEventKind for OnPromptSubmit {
|
impl HookEventKind for OnPromptSubmit {
|
||||||
type Input = PromptSubmitInfo;
|
type Input = PromptSubmitInfo;
|
||||||
@@ -345,11 +434,6 @@ impl HookEventKind for OnTurnEnd {
|
|||||||
type Output = HookTurnEndAction;
|
type Output = HookTurnEndAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for OnAbort {
|
|
||||||
type Input = AbortInfo;
|
|
||||||
type Output = ();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Hook Trait
|
// Hook Trait
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -362,25 +446,194 @@ impl HookEventKind for OnAbort {
|
|||||||
/// short-circuit on the first non-continue action.
|
/// short-circuit on the first non-continue action.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Hook<E: HookEventKind>: Send + Sync {
|
pub trait Hook<E: HookEventKind>: Send + Sync {
|
||||||
async fn call(&self, input: &E::Input) -> E::Output;
|
async fn call(&self, input: &E::Input) -> Result<E::Output, HookError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Hook Registry
|
// Hook Registry
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Bounded identity-only view of the committed history visible at a lifecycle
|
||||||
|
/// boundary. Hook context never carries history payloads; a Feature that needs
|
||||||
|
/// content must use a separately granted session-exploration service.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct HookHistoryRange {
|
||||||
|
pub first_entry_ref: Option<SessionEntryRef>,
|
||||||
|
pub last_entry_ref: Option<SessionEntryRef>,
|
||||||
|
pub entry_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable provenance attached to every Worker lifecycle callback.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct HookInvocationContext {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub session_revision: u64,
|
||||||
|
pub run_id: Option<String>,
|
||||||
|
pub turn_index: Option<usize>,
|
||||||
|
pub call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RunCommittedExit {
|
||||||
|
Finished,
|
||||||
|
Paused,
|
||||||
|
Yielded,
|
||||||
|
Interrupted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RunExitContext {
|
||||||
|
pub invocation: HookInvocationContext,
|
||||||
|
pub exit: RunCommittedExit,
|
||||||
|
pub history_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct RunCommittedContext {
|
||||||
|
pub invocation: HookInvocationContext,
|
||||||
|
pub exit: RunCommittedExit,
|
||||||
|
pub committed_history: HookHistoryRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SessionRewriteKind {
|
||||||
|
Rewind,
|
||||||
|
Compact,
|
||||||
|
Fork,
|
||||||
|
Restore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct BeforeSessionRewriteContext {
|
||||||
|
pub invocation: HookInvocationContext,
|
||||||
|
pub kind: SessionRewriteKind,
|
||||||
|
pub current_history: HookHistoryRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum BeforeSessionRewriteAction {
|
||||||
|
Continue,
|
||||||
|
Deny(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkerStoppingContext {
|
||||||
|
pub invocation: HookInvocationContext,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RunExit;
|
||||||
|
pub struct RunCommitted;
|
||||||
|
pub struct BeforeSessionRewrite;
|
||||||
|
pub struct WorkerStopping;
|
||||||
|
|
||||||
|
impl HookEventKind for RunExit {
|
||||||
|
type Input = RunExitContext;
|
||||||
|
type Output = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEventKind for RunCommitted {
|
||||||
|
type Input = RunCommittedContext;
|
||||||
|
type Output = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEventKind for BeforeSessionRewrite {
|
||||||
|
type Input = BeforeSessionRewriteContext;
|
||||||
|
type Output = BeforeSessionRewriteAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEventKind for WorkerStopping {
|
||||||
|
type Input = WorkerStoppingContext;
|
||||||
|
type Output = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RegisteredHook<E: HookEventKind> {
|
||||||
|
owner: String,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: Box<dyn Hook<E>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: HookEventKind> RegisteredHook<E> {
|
||||||
|
pub(crate) async fn call(&self, input: &E::Input) -> Result<E::Output, HookExecutionError> {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(self.policy.timeout_ms),
|
||||||
|
self.hook.call(input),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Err(HookError::new(
|
||||||
|
HookErrorCategory::Timeout,
|
||||||
|
"hook exceeded its host-enforced execution deadline",
|
||||||
|
))
|
||||||
|
});
|
||||||
|
result.map_err(|source| HookExecutionError {
|
||||||
|
owner: self.owner.clone(),
|
||||||
|
policy: self.policy.failure,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn call_optional(
|
||||||
|
&self,
|
||||||
|
input: &E::Input,
|
||||||
|
) -> Result<Option<E::Output>, HookExecutionError> {
|
||||||
|
match self.call(input).await {
|
||||||
|
Ok(output) => Ok(Some(output)),
|
||||||
|
Err(error) if error.policy == HookFailurePolicy::FailOpenWithDiagnostic => {
|
||||||
|
tracing::warn!(owner = %error.owner, error = %error.source, "inline hook failed open");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
#[error("hook `{owner}` failed under {policy:?}: {source}")]
|
||||||
|
pub struct HookExecutionError {
|
||||||
|
pub owner: String,
|
||||||
|
pub policy: HookFailurePolicy,
|
||||||
|
pub source: HookError,
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder for constructing a frozen `HookRegistry`.
|
/// Builder for constructing a frozen `HookRegistry`.
|
||||||
///
|
|
||||||
/// Hooks are added during setup, then `build()` produces an immutable
|
|
||||||
/// registry that can be shared via `Arc`.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HookRegistryBuilder {
|
pub struct HookRegistryBuilder {
|
||||||
on_prompt_submit: Vec<Box<dyn Hook<OnPromptSubmit>>>,
|
on_prompt_submit: Vec<RegisteredHook<OnPromptSubmit>>,
|
||||||
pre_llm_request: Vec<Box<dyn Hook<PreLlmRequest>>>,
|
pre_llm_request: Vec<RegisteredHook<PreLlmRequest>>,
|
||||||
pre_tool_call: Vec<Box<dyn Hook<PreToolCall>>>,
|
pre_tool_call: Vec<RegisteredHook<PreToolCall>>,
|
||||||
post_tool_call: Vec<Box<dyn Hook<PostToolCall>>>,
|
post_tool_call: Vec<RegisteredHook<PostToolCall>>,
|
||||||
on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
|
on_turn_end: Vec<RegisteredHook<OnTurnEnd>>,
|
||||||
on_abort: Vec<Box<dyn Hook<OnAbort>>>,
|
run_exit: Vec<RegisteredHook<RunExit>>,
|
||||||
|
run_committed: Vec<RegisteredHook<RunCommitted>>,
|
||||||
|
before_session_rewrite: Vec<RegisteredHook<BeforeSessionRewrite>>,
|
||||||
|
worker_stopping: Vec<RegisteredHook<WorkerStopping>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! add_hook_methods {
|
||||||
|
($default:ident, $named:ident, $field:ident, $event:ty) => {
|
||||||
|
pub fn $default(&mut self, hook: impl Hook<$event> + 'static) {
|
||||||
|
self.$named("worker.host", HookExecutionPolicy::fail_closed(), hook)
|
||||||
|
.expect("host hook policy is valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn $named(
|
||||||
|
&mut self,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
policy: HookExecutionPolicy,
|
||||||
|
hook: impl Hook<$event> + 'static,
|
||||||
|
) -> Result<(), HookError> {
|
||||||
|
let policy = policy.validate()?;
|
||||||
|
self.$field.push(RegisteredHook {
|
||||||
|
owner: owner.into(),
|
||||||
|
policy,
|
||||||
|
hook: Box::new(hook),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookRegistryBuilder {
|
impl HookRegistryBuilder {
|
||||||
@@ -388,31 +641,82 @@ impl HookRegistryBuilder {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_on_prompt_submit(&mut self, hook: impl Hook<OnPromptSubmit> + 'static) {
|
add_hook_methods!(
|
||||||
self.on_prompt_submit.push(Box::new(hook));
|
add_on_prompt_submit,
|
||||||
|
add_named_on_prompt_submit,
|
||||||
|
on_prompt_submit,
|
||||||
|
OnPromptSubmit
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_pre_llm_request,
|
||||||
|
add_named_pre_llm_request,
|
||||||
|
pre_llm_request,
|
||||||
|
PreLlmRequest
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_pre_tool_call,
|
||||||
|
add_named_pre_tool_call,
|
||||||
|
pre_tool_call,
|
||||||
|
PreToolCall
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_post_tool_call,
|
||||||
|
add_named_post_tool_call,
|
||||||
|
post_tool_call,
|
||||||
|
PostToolCall
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_on_turn_end,
|
||||||
|
add_named_on_turn_end,
|
||||||
|
on_turn_end,
|
||||||
|
OnTurnEnd
|
||||||
|
);
|
||||||
|
add_hook_methods!(add_run_exit, add_named_run_exit, run_exit, RunExit);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_run_committed,
|
||||||
|
add_named_run_committed,
|
||||||
|
run_committed,
|
||||||
|
RunCommitted
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_before_session_rewrite,
|
||||||
|
add_named_before_session_rewrite,
|
||||||
|
before_session_rewrite,
|
||||||
|
BeforeSessionRewrite
|
||||||
|
);
|
||||||
|
add_hook_methods!(
|
||||||
|
add_worker_stopping,
|
||||||
|
add_named_worker_stopping,
|
||||||
|
worker_stopping,
|
||||||
|
WorkerStopping
|
||||||
|
);
|
||||||
|
|
||||||
|
pub(crate) fn checkpoint(&self) -> [usize; 9] {
|
||||||
|
[
|
||||||
|
self.on_prompt_submit.len(),
|
||||||
|
self.pre_llm_request.len(),
|
||||||
|
self.pre_tool_call.len(),
|
||||||
|
self.post_tool_call.len(),
|
||||||
|
self.on_turn_end.len(),
|
||||||
|
self.run_exit.len(),
|
||||||
|
self.run_committed.len(),
|
||||||
|
self.before_session_rewrite.len(),
|
||||||
|
self.worker_stopping.len(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_pre_llm_request(&mut self, hook: impl Hook<PreLlmRequest> + 'static) {
|
pub(crate) fn rollback_to(&mut self, checkpoint: [usize; 9]) {
|
||||||
self.pre_llm_request.push(Box::new(hook));
|
self.on_prompt_submit.truncate(checkpoint[0]);
|
||||||
|
self.pre_llm_request.truncate(checkpoint[1]);
|
||||||
|
self.pre_tool_call.truncate(checkpoint[2]);
|
||||||
|
self.post_tool_call.truncate(checkpoint[3]);
|
||||||
|
self.on_turn_end.truncate(checkpoint[4]);
|
||||||
|
self.run_exit.truncate(checkpoint[5]);
|
||||||
|
self.run_committed.truncate(checkpoint[6]);
|
||||||
|
self.before_session_rewrite.truncate(checkpoint[7]);
|
||||||
|
self.worker_stopping.truncate(checkpoint[8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_pre_tool_call(&mut self, hook: impl Hook<PreToolCall> + 'static) {
|
|
||||||
self.pre_tool_call.push(Box::new(hook));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_post_tool_call(&mut self, hook: impl Hook<PostToolCall> + 'static) {
|
|
||||||
self.post_tool_call.push(Box::new(hook));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_on_turn_end(&mut self, hook: impl Hook<OnTurnEnd> + 'static) {
|
|
||||||
self.on_turn_end.push(Box::new(hook));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_on_abort(&mut self, hook: impl Hook<OnAbort> + 'static) {
|
|
||||||
self.on_abort.push(Box::new(hook));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Freeze the builder into an immutable registry.
|
|
||||||
pub fn build(self) -> HookRegistry {
|
pub fn build(self) -> HookRegistry {
|
||||||
HookRegistry {
|
HookRegistry {
|
||||||
on_prompt_submit: self.on_prompt_submit,
|
on_prompt_submit: self.on_prompt_submit,
|
||||||
@@ -420,19 +724,140 @@ impl HookRegistryBuilder {
|
|||||||
pre_tool_call: self.pre_tool_call,
|
pre_tool_call: self.pre_tool_call,
|
||||||
post_tool_call: self.post_tool_call,
|
post_tool_call: self.post_tool_call,
|
||||||
on_turn_end: self.on_turn_end,
|
on_turn_end: self.on_turn_end,
|
||||||
on_abort: self.on_abort,
|
run_exit: self.run_exit,
|
||||||
|
run_committed: self.run_committed,
|
||||||
|
before_session_rewrite: self.before_session_rewrite,
|
||||||
|
worker_stopping: self.worker_stopping,
|
||||||
|
diagnostics: std::sync::Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frozen registry of hooks. Constructed via [`HookRegistryBuilder::build()`].
|
/// Frozen registry of hooks. Constructed via [`HookRegistryBuilder::build()`].
|
||||||
pub struct HookRegistry {
|
pub struct HookRegistry {
|
||||||
pub(crate) on_prompt_submit: Vec<Box<dyn Hook<OnPromptSubmit>>>,
|
pub(crate) on_prompt_submit: Vec<RegisteredHook<OnPromptSubmit>>,
|
||||||
pub(crate) pre_llm_request: Vec<Box<dyn Hook<PreLlmRequest>>>,
|
pub(crate) pre_llm_request: Vec<RegisteredHook<PreLlmRequest>>,
|
||||||
pub(crate) pre_tool_call: Vec<Box<dyn Hook<PreToolCall>>>,
|
pub(crate) pre_tool_call: Vec<RegisteredHook<PreToolCall>>,
|
||||||
pub(crate) post_tool_call: Vec<Box<dyn Hook<PostToolCall>>>,
|
pub(crate) post_tool_call: Vec<RegisteredHook<PostToolCall>>,
|
||||||
pub(crate) on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
|
pub(crate) on_turn_end: Vec<RegisteredHook<OnTurnEnd>>,
|
||||||
pub(crate) on_abort: Vec<Box<dyn Hook<OnAbort>>>,
|
run_exit: Vec<RegisteredHook<RunExit>>,
|
||||||
|
run_committed: Vec<RegisteredHook<RunCommitted>>,
|
||||||
|
before_session_rewrite: Vec<RegisteredHook<BeforeSessionRewrite>>,
|
||||||
|
worker_stopping: Vec<RegisteredHook<WorkerStopping>>,
|
||||||
|
diagnostics: std::sync::Mutex<Vec<HookExecutionError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookRegistry {
|
||||||
|
fn record_diagnostic(&self, error: HookExecutionError) {
|
||||||
|
let mut diagnostics = self.diagnostics.lock().expect("hook diagnostics poisoned");
|
||||||
|
diagnostics.push(error);
|
||||||
|
if diagnostics.len() > 128 {
|
||||||
|
let remove = diagnostics.len() - 128;
|
||||||
|
diagnostics.drain(..remove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_chain_timeout(&self, lifecycle: &str) {
|
||||||
|
self.record_diagnostic(HookExecutionError {
|
||||||
|
owner: format!("worker.{lifecycle}"),
|
||||||
|
policy: HookFailurePolicy::AttentionRequired,
|
||||||
|
source: HookError::new(
|
||||||
|
HookErrorCategory::Timeout,
|
||||||
|
"hook chain exceeded its host-enforced lifecycle deadline",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagnostics(&self) -> Vec<HookExecutionError> {
|
||||||
|
self.diagnostics
|
||||||
|
.lock()
|
||||||
|
.expect("hook diagnostics poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
pub async fn on_run_exit(&self, context: &RunExitContext) -> Result<(), HookExecutionError> {
|
||||||
|
for registration in &self.run_exit {
|
||||||
|
if let Err(error) = registration.call(context).await {
|
||||||
|
self.record_diagnostic(error.clone());
|
||||||
|
match error.policy {
|
||||||
|
HookFailurePolicy::FailOpenWithDiagnostic => {
|
||||||
|
tracing::warn!(owner = %error.owner, error = %error.source, "run-exit hook failed open");
|
||||||
|
}
|
||||||
|
HookFailurePolicy::FailClosed | HookFailurePolicy::AttentionRequired => {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_run_committed(
|
||||||
|
&self,
|
||||||
|
context: &RunCommittedContext,
|
||||||
|
) -> Result<(), HookExecutionError> {
|
||||||
|
for registration in &self.run_committed {
|
||||||
|
if let Err(error) = registration.call(context).await {
|
||||||
|
self.record_diagnostic(error.clone());
|
||||||
|
match error.policy {
|
||||||
|
HookFailurePolicy::FailOpenWithDiagnostic => {
|
||||||
|
tracing::warn!(owner = %error.owner, error = %error.source, "run-committed hook failed open");
|
||||||
|
}
|
||||||
|
HookFailurePolicy::FailClosed | HookFailurePolicy::AttentionRequired => {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn before_session_rewrite(
|
||||||
|
&self,
|
||||||
|
context: &BeforeSessionRewriteContext,
|
||||||
|
) -> Result<BeforeSessionRewriteAction, HookExecutionError> {
|
||||||
|
let mut denials = Vec::new();
|
||||||
|
for registration in &self.before_session_rewrite {
|
||||||
|
match registration.call(context).await {
|
||||||
|
Ok(BeforeSessionRewriteAction::Continue) => {}
|
||||||
|
Ok(BeforeSessionRewriteAction::Deny(reason)) => {
|
||||||
|
denials.push((registration.owner.clone(), reason));
|
||||||
|
}
|
||||||
|
Err(error) if error.policy == HookFailurePolicy::FailOpenWithDiagnostic => {
|
||||||
|
self.record_diagnostic(error.clone());
|
||||||
|
tracing::warn!(owner = %error.owner, error = %error.source, "session-rewrite hook failed open");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.record_diagnostic(error.clone());
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
denials.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
|
Ok(denials
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|(_, reason)| BeforeSessionRewriteAction::Deny(reason))
|
||||||
|
.unwrap_or(BeforeSessionRewriteAction::Continue))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_worker_stopping(
|
||||||
|
&self,
|
||||||
|
context: &WorkerStoppingContext,
|
||||||
|
) -> Result<(), HookExecutionError> {
|
||||||
|
for registration in &self.worker_stopping {
|
||||||
|
if let Err(error) = registration.call(context).await {
|
||||||
|
self.record_diagnostic(error.clone());
|
||||||
|
match error.policy {
|
||||||
|
HookFailurePolicy::FailOpenWithDiagnostic
|
||||||
|
| HookFailurePolicy::AttentionRequired => {
|
||||||
|
tracing::warn!(owner = %error.owner, error = %error.source, "worker-stopping hook requires attention");
|
||||||
|
}
|
||||||
|
HookFailurePolicy::FailClosed => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -494,4 +919,172 @@ mod tests {
|
|||||||
let pause_action = HookPreToolAction::Pause.into_worker_action("call_4".into());
|
let pause_action = HookPreToolAction::Pause.into_worker_action("call_4".into());
|
||||||
assert!(matches!(pause_action, PreToolAction::Pause));
|
assert!(matches!(pause_action, PreToolAction::Pause));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RewriteHook {
|
||||||
|
action: BeforeSessionRewriteAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<BeforeSessionRewrite> for RewriteHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &BeforeSessionRewriteContext,
|
||||||
|
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||||
|
Ok(self.action.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailingRewriteHook;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<BeforeSessionRewrite> for FailingRewriteHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &BeforeSessionRewriteContext,
|
||||||
|
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||||
|
Err(HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
"provider unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_context() -> BeforeSessionRewriteContext {
|
||||||
|
BeforeSessionRewriteContext {
|
||||||
|
invocation: HookInvocationContext {
|
||||||
|
workspace_id: Some("workspace".into()),
|
||||||
|
worker_id: "worker".into(),
|
||||||
|
session_id: "session".into(),
|
||||||
|
session_revision: 4,
|
||||||
|
run_id: None,
|
||||||
|
turn_index: None,
|
||||||
|
call_id: None,
|
||||||
|
},
|
||||||
|
kind: SessionRewriteKind::Compact,
|
||||||
|
current_history: HookHistoryRange::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rewrite_denials_are_resolved_by_owner_not_registration_order() {
|
||||||
|
let mut builder = HookRegistryBuilder::new();
|
||||||
|
builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"z-feature",
|
||||||
|
HookExecutionPolicy::fail_closed(),
|
||||||
|
RewriteHook {
|
||||||
|
action: BeforeSessionRewriteAction::Deny("z denied".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"a-feature",
|
||||||
|
HookExecutionPolicy::fail_closed(),
|
||||||
|
RewriteHook {
|
||||||
|
action: BeforeSessionRewriteAction::Deny("a denied".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.before_session_rewrite(&rewrite_context())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
BeforeSessionRewriteAction::Deny("a denied".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hook_failure_policy_is_applied_at_the_registry_boundary() {
|
||||||
|
let mut fail_open = HookRegistryBuilder::new();
|
||||||
|
fail_open
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"feature",
|
||||||
|
HookExecutionPolicy::new(HookFailurePolicy::FailOpenWithDiagnostic, 30_000),
|
||||||
|
FailingRewriteHook,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let fail_open = fail_open.build();
|
||||||
|
assert_eq!(
|
||||||
|
fail_open
|
||||||
|
.before_session_rewrite(&rewrite_context())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
BeforeSessionRewriteAction::Continue
|
||||||
|
);
|
||||||
|
assert_eq!(fail_open.diagnostics().len(), 1);
|
||||||
|
|
||||||
|
let mut fail_closed = HookRegistryBuilder::new();
|
||||||
|
fail_closed
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"feature",
|
||||||
|
HookExecutionPolicy::fail_closed(),
|
||||||
|
FailingRewriteHook,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = fail_closed
|
||||||
|
.build()
|
||||||
|
.before_session_rewrite(&rewrite_context())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.source.category, HookErrorCategory::Dependency);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NeverReturns;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<BeforeSessionRewrite> for NeverReturns {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &BeforeSessionRewriteContext,
|
||||||
|
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hook_execution_deadline_cancels_a_non_cooperative_callback() {
|
||||||
|
let mut builder = HookRegistryBuilder::new();
|
||||||
|
builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"feature",
|
||||||
|
HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 10),
|
||||||
|
NeverReturns,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let registry = builder.build();
|
||||||
|
|
||||||
|
let error = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
registry.before_session_rewrite(&rewrite_context()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("host hook deadline must terminate the callback")
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.source.category, HookErrorCategory::Timeout);
|
||||||
|
assert_eq!(registry.diagnostics().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_hook_execution_budget_is_rejected_before_registration() {
|
||||||
|
let mut builder = HookRegistryBuilder::new();
|
||||||
|
let error = builder
|
||||||
|
.add_named_before_session_rewrite(
|
||||||
|
"feature",
|
||||||
|
HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 0),
|
||||||
|
NeverReturns,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.category, HookErrorCategory::InvalidInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hook_diagnostics_are_utf8_bounded() {
|
||||||
|
let error = HookError::new(HookErrorCategory::Internal, "界".repeat(1_000));
|
||||||
|
assert!(error.diagnostic.len() <= HOOK_DIAGNOSTIC_MAX_BYTES);
|
||||||
|
assert!(error.diagnostic.is_char_boundary(error.diagnostic.len()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,14 +123,14 @@ where
|
|||||||
|
|
||||||
// Internal identities are run-scoped and never enter the public Runtime Worker catalog.
|
// Internal identities are run-scoped and never enter the public Runtime Worker catalog.
|
||||||
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||||
// Internal jobs only receive features supplied below. A parent manifest must not accidentally
|
// Internal jobs only receive the explicitly supplied Feature set below. A
|
||||||
// grant its normal public tool surface or recursively schedule memory work.
|
// parent manifest cannot accidentally grant its normal public tool surface
|
||||||
|
// or recursively schedule Feature-owned background work.
|
||||||
manifest.feature = Default::default();
|
manifest.feature = Default::default();
|
||||||
manifest.plugins = Default::default();
|
manifest.plugins = Default::default();
|
||||||
manifest.mcp = Default::default();
|
manifest.mcp = Default::default();
|
||||||
manifest.skills = None;
|
manifest.skills = None;
|
||||||
manifest.compaction = None;
|
manifest.compaction = None;
|
||||||
manifest.memory = None;
|
|
||||||
|
|
||||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||||
let usage_slot = last_usage.clone();
|
let usage_slot = last_usage.clone();
|
||||||
@@ -164,6 +164,7 @@ where
|
|||||||
identity: identity.clone(),
|
identity: identity.clone(),
|
||||||
history_entries: 0,
|
history_entries: 0,
|
||||||
})?;
|
})?;
|
||||||
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_workdir_session(Some(session));
|
worker.bind_workdir_session(Some(session));
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,7 @@ where
|
|||||||
let segment_id = worker.segment_id();
|
let segment_id = worker.segment_id();
|
||||||
on_cancel_sender(worker.engine_mut().cancel_sender());
|
on_cancel_sender(worker.engine_mut().cancel_sender());
|
||||||
|
|
||||||
match worker.run_text(&input).await {
|
let outcome = match worker.run_text(&input).await {
|
||||||
Ok(lifecycle @ WorkerRunResult::Finished)
|
Ok(lifecycle @ WorkerRunResult::Finished)
|
||||||
| Ok(lifecycle @ WorkerRunResult::Paused)
|
| Ok(lifecycle @ WorkerRunResult::Paused)
|
||||||
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
|
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
|
||||||
@@ -239,7 +240,11 @@ where
|
|||||||
identity,
|
identity,
|
||||||
history_entries: store.entries_count(session_id, segment_id),
|
history_entries: store.entries_count(session_id, segment_id),
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
|
worker
|
||||||
|
.stop_feature_runtime("internal Worker terminal outcome")
|
||||||
|
.await;
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -544,7 +549,6 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
authority,
|
authority,
|
||||||
} = spec;
|
} = spec;
|
||||||
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||||
manifest.memory = None;
|
|
||||||
|
|
||||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||||
let usage_slot = last_usage.clone();
|
let usage_slot = last_usage.clone();
|
||||||
@@ -575,6 +579,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_workdir_session(Some(session));
|
worker.bind_workdir_session(Some(session));
|
||||||
}
|
}
|
||||||
@@ -645,7 +650,6 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
manifest.mcp = Default::default();
|
manifest.mcp = Default::default();
|
||||||
manifest.skills = None;
|
manifest.skills = None;
|
||||||
manifest.compaction = None;
|
manifest.compaction = None;
|
||||||
manifest.memory = None;
|
|
||||||
|
|
||||||
let mut engine =
|
let mut engine =
|
||||||
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||||
@@ -669,6 +673,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_workdir_session(Some(session));
|
worker.bind_workdir_session(Some(session));
|
||||||
}
|
}
|
||||||
@@ -782,7 +787,8 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(command) = command_rx.recv().await {
|
let mut stop_done = None;
|
||||||
|
'actor: while let Some(command) = command_rx.recv().await {
|
||||||
match command {
|
match command {
|
||||||
InternalWorkerSessionCommand::Run(input) => {
|
InternalWorkerSessionCommand::Run(input) => {
|
||||||
actor_in_flight.clear();
|
actor_in_flight.clear();
|
||||||
@@ -825,21 +831,16 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
Some(InternalWorkerSessionCommand::Stop(done)) => {
|
||||||
let _ = cancel_sender.send(()).await;
|
let _ = cancel_sender.send(()).await;
|
||||||
let _ = (&mut run).await;
|
let _ = (&mut run).await;
|
||||||
actor_in_flight.clear();
|
stop_done = Some(done);
|
||||||
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
|
break 'actor;
|
||||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
|
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
|
||||||
state_changed.notify_waiters();
|
|
||||||
let _ = done.send(());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Some(InternalWorkerSessionCommand::Run(_)) => {
|
Some(InternalWorkerSessionCommand::Run(_)) => {
|
||||||
// `send` reserves Running atomically, so a second Run cannot be enqueued.
|
// `send` reserves Running atomically, so a second Run cannot be enqueued.
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let _ = cancel_sender.send(()).await;
|
let _ = cancel_sender.send(()).await;
|
||||||
actor_in_flight.clear();
|
let _ = (&mut run).await;
|
||||||
return;
|
break 'actor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,22 +848,27 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
InternalWorkerSessionCommand::Stop(done) => {
|
InternalWorkerSessionCommand::Stop(done) => {
|
||||||
actor_in_flight.clear();
|
stop_done = Some(done);
|
||||||
status.store(
|
break;
|
||||||
InternalWorkerSessionStatus::Stopped.encode(),
|
|
||||||
std::sync::atomic::Ordering::Release,
|
|
||||||
);
|
|
||||||
let _ = event_tx.send(Event::Status {
|
|
||||||
status: WorkerStatus::Stopped,
|
|
||||||
});
|
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
|
||||||
state_changed.notify_waiters();
|
|
||||||
let _ = done.send(());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
worker
|
||||||
|
.stop_feature_runtime("internal Worker session stopped")
|
||||||
|
.await;
|
||||||
actor_in_flight.clear();
|
actor_in_flight.clear();
|
||||||
|
status.store(
|
||||||
|
InternalWorkerSessionStatus::Stopped.encode(),
|
||||||
|
std::sync::atomic::Ordering::Release,
|
||||||
|
);
|
||||||
|
let _ = event_tx.send(Event::Status {
|
||||||
|
status: WorkerStatus::Stopped,
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(Event::Shutdown);
|
||||||
|
state_changed.notify_waiters();
|
||||||
|
if let Some(done) = stop_done {
|
||||||
|
let _ = done.send(());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ use std::sync::{Arc, Mutex};
|
|||||||
use agen::Item;
|
use agen::Item;
|
||||||
use agen::UsageRecord;
|
use agen::UsageRecord;
|
||||||
use agen::interceptor::{
|
use agen::interceptor::{
|
||||||
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
|
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
|
||||||
|
InterceptorResult, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
|
||||||
|
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, ToolCallInfo,
|
||||||
ToolResultInfo, TurnEndAction,
|
ToolResultInfo, TurnEndAction,
|
||||||
};
|
};
|
||||||
use agen::tool::ToolOutput;
|
use agen::tool::ToolOutput;
|
||||||
@@ -28,9 +30,9 @@ use crate::compact::usage_tracker::UsageTracker;
|
|||||||
use session_store::SystemItem;
|
use session_store::SystemItem;
|
||||||
|
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
|
HookEventKind, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
|
||||||
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
|
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
|
||||||
SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
|
RegisteredHook, SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
|
||||||
};
|
};
|
||||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
@@ -41,6 +43,32 @@ use agen::token_counter::total_tokens;
|
|||||||
|
|
||||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||||
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
||||||
|
const INLINE_HOOK_CHAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
async fn call_hook_before_deadline<E: HookEventKind>(
|
||||||
|
hook: &RegisteredHook<E>,
|
||||||
|
input: &E::Input,
|
||||||
|
deadline: tokio::time::Instant,
|
||||||
|
) -> Result<Option<E::Output>, InterceptorError> {
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Policy,
|
||||||
|
"Worker hook chain exceeded its host-enforced lifecycle deadline",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::timeout(remaining, hook.call_optional(input))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Policy,
|
||||||
|
"Worker hook chain exceeded its host-enforced lifecycle deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|error| {
|
||||||
|
InterceptorError::new(InterceptorErrorCategory::Dependency, error.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct WorkerInterceptor {
|
pub(crate) struct WorkerInterceptor {
|
||||||
registry: Arc<HookRegistry>,
|
registry: Arc<HookRegistry>,
|
||||||
@@ -231,8 +259,12 @@ impl WorkerInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for WorkerInterceptor {
|
impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||||
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
|
async fn on_prompt_submit(
|
||||||
|
&self,
|
||||||
|
context: PromptSubmitContext<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<PromptAction> {
|
||||||
|
let item = context.item;
|
||||||
let turn_index = self.next_turn_index.fetch_add(1, Ordering::Relaxed);
|
let turn_index = self.next_turn_index.fetch_add(1, Ordering::Relaxed);
|
||||||
self.tool_calls_this_turn.store(0, Ordering::Relaxed);
|
self.tool_calls_this_turn.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
@@ -240,19 +272,27 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
input_text: extract_message_text(item).unwrap_or_default(),
|
input_text: extract_message_text(item).unwrap_or_default(),
|
||||||
turn_index,
|
turn_index,
|
||||||
};
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
|
||||||
|
let mut cancellations = Vec::new();
|
||||||
for hook in &self.registry.on_prompt_submit {
|
for hook in &self.registry.on_prompt_submit {
|
||||||
let action = hook.call(&info).await;
|
let Some(action) = call_hook_before_deadline(hook, &info, deadline).await? else {
|
||||||
if !matches!(action, HookPromptAction::Continue) {
|
continue;
|
||||||
return action.into();
|
};
|
||||||
|
if let HookPromptAction::Cancel(reason) = action {
|
||||||
|
cancellations.push(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cancellations.sort();
|
||||||
|
if let Some(reason) = cancellations.into_iter().next() {
|
||||||
|
return Ok(PromptAction::Cancel(reason));
|
||||||
|
}
|
||||||
let mut extras: Vec<SystemItem> = std::mem::take(
|
let mut extras: Vec<SystemItem> = std::mem::take(
|
||||||
&mut *self
|
&mut *self
|
||||||
.pending_attachments
|
.pending_attachments
|
||||||
.lock()
|
.lock()
|
||||||
.expect("pending_attachments poisoned"),
|
.expect("pending_attachments poisoned"),
|
||||||
);
|
);
|
||||||
if extras.is_empty() {
|
Ok(if extras.is_empty() {
|
||||||
PromptAction::Continue
|
PromptAction::Continue
|
||||||
} else {
|
} else {
|
||||||
// Commit the typed system items first, then hand the
|
// Commit the typed system items first, then hand the
|
||||||
@@ -266,10 +306,13 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
Ok(()) => PromptAction::ContinueWith(items),
|
Ok(()) => PromptAction::ContinueWith(items),
|
||||||
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
|
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
async fn pending_history_appends(
|
||||||
|
&self,
|
||||||
|
_context: PendingHistoryAppendsContext<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<Vec<Item>> {
|
||||||
let drained = self.pending_notifies.drain();
|
let drained = self.pending_notifies.drain();
|
||||||
if drained.is_empty() {
|
if drained.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -295,7 +338,10 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
Ok(system_item) => system_item,
|
Ok(system_item) => system_item,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.pending_notifies.requeue_front(drained);
|
self.pending_notifies.requeue_front(drained);
|
||||||
return Err(format!("failed to render notify_wrapper: {error}"));
|
return Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Dependency,
|
||||||
|
format!("failed to render notify_wrapper: {error}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
items.push(system_item.to_history_item());
|
items.push(system_item.to_history_item());
|
||||||
@@ -303,15 +349,22 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
}
|
}
|
||||||
if let Err(error) = self.commit_system_items(&system_items) {
|
if let Err(error) = self.commit_system_items(&system_items) {
|
||||||
self.pending_notifies.requeue_front(drained);
|
self.pending_notifies.requeue_front(drained);
|
||||||
return Err(format!("session persistence failed: {error}"));
|
return Err(InterceptorError::new(
|
||||||
|
InterceptorErrorCategory::Dependency,
|
||||||
|
format!("session persistence failed: {error}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(
|
||||||
|
&self,
|
||||||
|
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
let context = context.items;
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||||
return PreRequestAction::Yield;
|
return Ok(PreRequestAction::Yield);
|
||||||
}
|
}
|
||||||
let info = PreRequestInfo {
|
let info = PreRequestInfo {
|
||||||
item_count: context.len(),
|
item_count: context.len(),
|
||||||
@@ -325,12 +378,28 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| SystemItemAppendHandle::new(Arc::clone(&pending_hook_system_items)));
|
.map(|_| SystemItemAppendHandle::new(Arc::clone(&pending_hook_system_items)));
|
||||||
let hook_context = PreRequestContext::new(info, system_item_sink);
|
let hook_context = PreRequestContext::new(info, system_item_sink);
|
||||||
|
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
|
||||||
|
let mut cancellations = Vec::new();
|
||||||
|
let mut should_yield = false;
|
||||||
for hook in &self.registry.pre_llm_request {
|
for hook in &self.registry.pre_llm_request {
|
||||||
let action = hook.call(&hook_context).await;
|
let Some(action) = call_hook_before_deadline(hook, &hook_context, deadline).await?
|
||||||
if !matches!(action, HookPreRequestAction::Continue) {
|
else {
|
||||||
return action.into();
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match action {
|
||||||
|
HookPreRequestAction::Continue => {}
|
||||||
|
HookPreRequestAction::Yield => should_yield = true,
|
||||||
|
HookPreRequestAction::Cancel(reason) => cancellations.push(reason),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cancellations.sort();
|
||||||
|
if let Some(reason) = cancellations.into_iter().next() {
|
||||||
|
return Ok(PreRequestAction::Cancel(reason));
|
||||||
|
}
|
||||||
|
if should_yield {
|
||||||
|
return Ok(PreRequestAction::Yield);
|
||||||
|
}
|
||||||
|
|
||||||
let mut system_items: Vec<SystemItem> = std::mem::take(
|
let mut system_items: Vec<SystemItem> = std::mem::take(
|
||||||
&mut *pending_hook_system_items
|
&mut *pending_hook_system_items
|
||||||
@@ -353,44 +422,73 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
|
|
||||||
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
|
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
|
||||||
if let Err(error) = self.commit_system_items(&system_items) {
|
if let Err(error) = self.commit_system_items(&system_items) {
|
||||||
return PreRequestAction::Cancel(format!("session persistence failed: {error}"));
|
return Ok(PreRequestAction::Cancel(format!(
|
||||||
|
"session persistence failed: {error}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
return if appended_items.is_empty() {
|
return Ok(if appended_items.is_empty() {
|
||||||
PreRequestAction::Yield
|
PreRequestAction::Yield
|
||||||
} else {
|
} else {
|
||||||
PreRequestAction::YieldWith(appended_items)
|
PreRequestAction::YieldWith(appended_items)
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
||||||
usage_tracker.note_request(effective_context.len());
|
usage_tracker.note_request(effective_context.len());
|
||||||
}
|
}
|
||||||
if system_items.is_empty() {
|
if system_items.is_empty() {
|
||||||
return PreRequestAction::Continue;
|
return Ok(PreRequestAction::Continue);
|
||||||
}
|
}
|
||||||
match self.commit_system_items(&system_items) {
|
Ok(match self.commit_system_items(&system_items) {
|
||||||
Ok(()) => PreRequestAction::ContinueWith(appended_items),
|
Ok(()) => PreRequestAction::ContinueWith(appended_items),
|
||||||
Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")),
|
Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &mut ToolCallInfo<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
let summary = ToolCallSummary {
|
let summary = ToolCallSummary {
|
||||||
call_id: info.call.id.clone(),
|
call_id: info.call.id.clone(),
|
||||||
tool_name: info.call.name.clone(),
|
tool_name: info.call.name.clone(),
|
||||||
arguments: info.call.input.clone(),
|
arguments: info.call.input.clone(),
|
||||||
};
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
|
||||||
|
let mut aborts = Vec::new();
|
||||||
|
let mut should_pause = false;
|
||||||
|
let mut denials = Vec::new();
|
||||||
for hook in &self.registry.pre_tool_call {
|
for hook in &self.registry.pre_tool_call {
|
||||||
let action = hook.call(&summary).await;
|
let Some(action) = call_hook_before_deadline(hook, &summary, deadline).await? else {
|
||||||
if !matches!(action, HookPreToolAction::Continue) {
|
continue;
|
||||||
return action.into_worker_action(summary.call_id.clone());
|
};
|
||||||
|
|
||||||
|
match action {
|
||||||
|
HookPreToolAction::Continue => {}
|
||||||
|
HookPreToolAction::Pause => should_pause = true,
|
||||||
|
HookPreToolAction::Deny(reason) => denials.push(reason),
|
||||||
|
HookPreToolAction::Abort(reason) => aborts.push(reason),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
aborts.sort();
|
||||||
|
if let Some(reason) = aborts.into_iter().next() {
|
||||||
|
return Ok(HookPreToolAction::Abort(reason).into_worker_action(summary.call_id.clone()));
|
||||||
|
}
|
||||||
|
if should_pause {
|
||||||
|
return Ok(PreToolAction::Pause);
|
||||||
|
}
|
||||||
|
denials.sort();
|
||||||
|
if let Some(reason) = denials.into_iter().next() {
|
||||||
|
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
|
||||||
|
}
|
||||||
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
||||||
PreToolAction::Continue
|
Ok(PreToolAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
async fn post_tool_call(
|
||||||
|
&self,
|
||||||
|
info: &ToolResultInfo<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<PostToolAction> {
|
||||||
let summary = ToolResultSummary {
|
let summary = ToolResultSummary {
|
||||||
call_id: info.result.tool_use_id.clone(),
|
call_id: info.result.tool_use_id.clone(),
|
||||||
tool_name: info.call.name.clone(),
|
tool_name: info.call.name.clone(),
|
||||||
@@ -402,21 +500,34 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
|
||||||
|
let mut aborts = Vec::new();
|
||||||
for hook in &self.registry.post_tool_call {
|
for hook in &self.registry.post_tool_call {
|
||||||
let action = hook.call(&summary).await;
|
let Some(action) = call_hook_before_deadline(hook, &summary, deadline).await? else {
|
||||||
if !matches!(action, HookPostToolAction::Continue) {
|
continue;
|
||||||
return action.into();
|
};
|
||||||
|
|
||||||
|
if let HookPostToolAction::Abort(reason) = action {
|
||||||
|
aborts.push(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PostToolAction::Continue
|
aborts.sort();
|
||||||
|
if let Some(reason) = aborts.into_iter().next() {
|
||||||
|
return Ok(PostToolAction::Abort(reason));
|
||||||
|
}
|
||||||
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
|
async fn on_assistant_turn_end(
|
||||||
|
&self,
|
||||||
|
context: AssistantTurnEndContext<'_, SessionHistoryMetadata>,
|
||||||
|
) -> InterceptorResult<TurnEndAction> {
|
||||||
|
let history = context.history;
|
||||||
let final_text_preview = history
|
let final_text_preview = history
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|i| i.is_assistant_message())
|
.find(|entry| entry.item.is_assistant_message())
|
||||||
.and_then(extract_message_text)
|
.and_then(|entry| extract_message_text(&entry.item))
|
||||||
.map(|t| preview(&t, FINAL_TEXT_PREVIEW_LIMIT))
|
.map(|t| preview(&t, FINAL_TEXT_PREVIEW_LIMIT))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let info = TurnEndInfo {
|
let info = TurnEndInfo {
|
||||||
@@ -424,22 +535,20 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
tool_calls_count: self.tool_calls_this_turn.load(Ordering::Relaxed),
|
tool_calls_count: self.tool_calls_this_turn.load(Ordering::Relaxed),
|
||||||
final_text_preview,
|
final_text_preview,
|
||||||
};
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
|
||||||
|
let mut should_pause = false;
|
||||||
for hook in &self.registry.on_turn_end {
|
for hook in &self.registry.on_turn_end {
|
||||||
let action = hook.call(&info).await;
|
let Some(action) = call_hook_before_deadline(hook, &info, deadline).await? else {
|
||||||
if !matches!(action, HookTurnEndAction::Finish) {
|
continue;
|
||||||
return action.into();
|
};
|
||||||
|
if matches!(action, HookTurnEndAction::Pause) {
|
||||||
|
should_pause = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnEndAction::Finish
|
if should_pause {
|
||||||
}
|
return Ok(TurnEndAction::Pause);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
Ok(TurnEndAction::Finish)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +618,7 @@ mod tests {
|
|||||||
Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder,
|
Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder,
|
||||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||||
};
|
};
|
||||||
|
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||||
|
|
||||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||||
@@ -518,9 +628,12 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for CountingHook {
|
impl Hook<PreLlmRequest> for CountingHook {
|
||||||
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_info: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
self.0.fetch_add(1, Ordering::Relaxed);
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
HookPreRequestAction::Continue
|
Ok(HookPreRequestAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,16 +672,22 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for AppendingPreRequestHook {
|
impl Hook<PreLlmRequest> for AppendingPreRequestHook {
|
||||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
if let Some(system_items) = input.system_items() {
|
if let Some(system_items) = input.system_items() {
|
||||||
self.saw_handle.store(true, Ordering::Relaxed);
|
self.saw_handle.store(true, Ordering::Relaxed);
|
||||||
system_items.append_task_reminder("hook reminder");
|
system_items.append_task_reminder("hook reminder");
|
||||||
}
|
}
|
||||||
HookPreRequestAction::Continue
|
Ok(HookPreRequestAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn task_tool_call_info(name: &str, input: serde_json::Value) -> ToolCallInfo {
|
fn task_tool_call_info(
|
||||||
|
name: &str,
|
||||||
|
input: serde_json::Value,
|
||||||
|
) -> ToolCallInfo<'static, SessionHistoryMetadata> {
|
||||||
let def = crate::feature::builtin::task::task_tools(
|
let def = crate::feature::builtin::task::task_tools(
|
||||||
crate::feature::builtin::task::TaskStore::new(),
|
crate::feature::builtin::task::TaskStore::new(),
|
||||||
)
|
)
|
||||||
@@ -580,6 +699,8 @@ mod tests {
|
|||||||
.expect("task tool definition");
|
.expect("task tool definition");
|
||||||
let (meta, tool) = def();
|
let (meta, tool) = def();
|
||||||
ToolCallInfo {
|
ToolCallInfo {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
call: agen::tool::ToolCall {
|
call: agen::tool::ToolCall {
|
||||||
id: "call-id".into(),
|
id: "call-id".into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
@@ -623,7 +744,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Yield));
|
assert!(matches!(action, PreRequestAction::Yield));
|
||||||
// Hook must not run when an internal mechanism short-circuits first.
|
// Hook must not run when an internal mechanism short-circuits first.
|
||||||
@@ -655,7 +783,14 @@ mod tests {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
||||||
@@ -692,7 +827,14 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.with_usage_tracker(usage_tracker);
|
.with_usage_tracker(usage_tracker);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Yield));
|
assert!(matches!(action, PreRequestAction::Yield));
|
||||||
}
|
}
|
||||||
@@ -716,7 +858,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -757,7 +906,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -784,7 +940,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -805,7 +968,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -834,7 +1004,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(saw_handle.load(Ordering::Relaxed));
|
assert!(saw_handle.load(Ordering::Relaxed));
|
||||||
let PreRequestAction::ContinueWith(items) = action else {
|
let PreRequestAction::ContinueWith(items) = action else {
|
||||||
@@ -881,7 +1058,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(!saw_handle.load(Ordering::Relaxed));
|
assert!(!saw_handle.load(Ordering::Relaxed));
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
@@ -891,33 +1075,42 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreLlmRequest> for AbortingHook {
|
impl Hook<PreLlmRequest> for AbortingHook {
|
||||||
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_info: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
self.0.store(true, Ordering::Relaxed);
|
self.0.store(true, Ordering::Relaxed);
|
||||||
HookPreRequestAction::Cancel("nope".into())
|
Ok(HookPreRequestAction::Cancel("nope".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn public_pre_tool_hook_deny_becomes_synthetic_error_and_short_circuits() {
|
async fn public_pre_tool_hook_denials_compose_without_short_circuiting() {
|
||||||
struct DenyToolHook(Arc<AtomicUsize>);
|
struct DenyToolHook(Arc<AtomicUsize>);
|
||||||
struct CountingToolHook(Arc<AtomicUsize>);
|
struct CountingToolHook(Arc<AtomicUsize>);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreToolCall> for DenyToolHook {
|
impl Hook<PreToolCall> for DenyToolHook {
|
||||||
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &ToolCallSummary,
|
||||||
|
) -> Result<HookPreToolAction, crate::hook::HookError> {
|
||||||
self.0.fetch_add(1, Ordering::Relaxed);
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
assert_eq!(input.call_id, "call-id");
|
assert_eq!(input.call_id, "call-id");
|
||||||
assert_eq!(input.tool_name, "TaskList");
|
assert_eq!(input.tool_name, "TaskList");
|
||||||
assert_eq!(input.arguments, serde_json::json!({"scope": "all"}));
|
assert_eq!(input.arguments, serde_json::json!({"scope": "all"}));
|
||||||
HookPreToolAction::Deny("blocked by public hook".into())
|
Ok(HookPreToolAction::Deny("blocked by public hook".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreToolCall> for CountingToolHook {
|
impl Hook<PreToolCall> for CountingToolHook {
|
||||||
async fn call(&self, _input: &ToolCallSummary) -> HookPreToolAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_input: &ToolCallSummary,
|
||||||
|
) -> Result<HookPreToolAction, crate::hook::HookError> {
|
||||||
self.0.fetch_add(1, Ordering::Relaxed);
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
HookPreToolAction::Continue
|
Ok(HookPreToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,7 +1131,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
||||||
|
|
||||||
let action = interceptor.pre_tool_call(&mut info).await;
|
let action = interceptor.pre_tool_call(&mut info).await.unwrap();
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
PreToolAction::SyntheticResult(result) => {
|
PreToolAction::SyntheticResult(result) => {
|
||||||
@@ -950,7 +1143,7 @@ mod tests {
|
|||||||
other => panic!("expected synthetic denial, got {other:?}"),
|
other => panic!("expected synthetic denial, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert_eq!(first_count.load(Ordering::Relaxed), 1);
|
assert_eq!(first_count.load(Ordering::Relaxed), 1);
|
||||||
assert_eq!(second_count.load(Ordering::Relaxed), 0);
|
assert_eq!(second_count.load(Ordering::Relaxed), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -959,14 +1152,17 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PostToolCall> for AbortAfterToolHook {
|
impl Hook<PostToolCall> for AbortAfterToolHook {
|
||||||
async fn call(&self, input: &ToolResultSummary) -> HookPostToolAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &ToolResultSummary,
|
||||||
|
) -> Result<HookPostToolAction, crate::hook::HookError> {
|
||||||
self.0.fetch_add(1, Ordering::Relaxed);
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
assert_eq!(input.call_id, "call-id");
|
assert_eq!(input.call_id, "call-id");
|
||||||
assert_eq!(input.tool_name, "TaskList");
|
assert_eq!(input.tool_name, "TaskList");
|
||||||
assert!(!input.is_error);
|
assert!(!input.is_error);
|
||||||
assert_eq!(input.output.summary, "ok");
|
assert_eq!(input.output.summary, "ok");
|
||||||
assert_eq!(input.output.content.as_deref(), Some("full"));
|
assert_eq!(input.output.content.as_deref(), Some("full"));
|
||||||
HookPostToolAction::Abort("post tool abort".into())
|
Ok(HookPostToolAction::Abort("post tool abort".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,7 +1180,9 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
||||||
let mut result_info = ToolResultInfo {
|
let result_info = ToolResultInfo {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
call: info.call,
|
call: info.call,
|
||||||
result: agen::tool::ToolResult::from_output(
|
result: agen::tool::ToolResult::from_output(
|
||||||
"call-id",
|
"call-id",
|
||||||
@@ -1000,7 +1198,7 @@ mod tests {
|
|||||||
context: info.context,
|
context: info.context,
|
||||||
};
|
};
|
||||||
|
|
||||||
let action = interceptor.post_tool_call(&mut result_info).await;
|
let action = interceptor.post_tool_call(&result_info).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(action, PostToolAction::Abort("post tool abort".to_string()));
|
assert_eq!(action, PostToolAction::Abort("post tool abort".to_string()));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -1012,12 +1210,15 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<OnTurnEnd> for PauseTurnEndHook {
|
impl Hook<OnTurnEnd> for PauseTurnEndHook {
|
||||||
async fn call(&self, input: &TurnEndInfo) -> HookTurnEndAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
input: &TurnEndInfo,
|
||||||
|
) -> Result<HookTurnEndAction, crate::hook::HookError> {
|
||||||
self.0.fetch_add(1, Ordering::Relaxed);
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
assert_eq!(input.turn_index, 0);
|
assert_eq!(input.turn_index, 0);
|
||||||
assert_eq!(input.tool_calls_count, 0);
|
assert_eq!(input.tool_calls_count, 0);
|
||||||
assert_eq!(input.final_text_preview, "done");
|
assert_eq!(input.final_text_preview, "done");
|
||||||
HookTurnEndAction::Pause
|
Ok(HookTurnEndAction::Pause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,9 +1235,25 @@ mod tests {
|
|||||||
test_prompts(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
let history = vec![
|
||||||
|
history_entry(
|
||||||
let action = interceptor.on_turn_end(&history).await;
|
Item::user_message("hi"),
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown,
|
||||||
|
),
|
||||||
|
history_entry(
|
||||||
|
Item::assistant_message("done"),
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let action = interceptor
|
||||||
|
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
assistant_entries: &history[1..],
|
||||||
|
history: &history,
|
||||||
|
tool_calls: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, TurnEndAction::Pause));
|
assert!(matches!(action, TurnEndAction::Pause));
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||||
@@ -1073,7 +1290,14 @@ mod tests {
|
|||||||
let ctx_items = vec![Item::user_message("hi")];
|
let ctx_items = vec![Item::user_message("hi")];
|
||||||
for _ in 0..23 {
|
for _ in 0..23 {
|
||||||
let mut ctx = ctx_items.clone();
|
let mut ctx = ctx_items.clone();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
usage_tracker.record_usage(&agen::event::UsageEvent {
|
usage_tracker.record_usage(&agen::event::UsageEvent {
|
||||||
input_tokens: Some(10),
|
input_tokens: Some(10),
|
||||||
@@ -1085,7 +1309,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut ctx = ctx_items.clone();
|
let mut ctx = ctx_items.clone();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let appended_len = match action {
|
let appended_len = match action {
|
||||||
PreRequestAction::ContinueWith(items) => items.len(),
|
PreRequestAction::ContinueWith(items) => items.len(),
|
||||||
other => panic!("expected reminder append, got {other:?}"),
|
other => panic!("expected reminder append, got {other:?}"),
|
||||||
@@ -1158,7 +1389,13 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
buffer.push_notify("updated".to_string(), false);
|
buffer.push_notify("updated".to_string(), false);
|
||||||
let appends = interceptor.pending_history_appends().await.unwrap();
|
let appends = interceptor
|
||||||
|
.pending_history_appends(PendingHistoryAppendsContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(appends.len(), 1);
|
assert_eq!(appends.len(), 1);
|
||||||
assert!(format!("{:?}", appends[0]).contains("CURRENT-PROJECTION updated"));
|
assert!(format!("{:?}", appends[0]).contains("CURRENT-PROJECTION updated"));
|
||||||
let committed = committed.lock().unwrap();
|
let committed = committed.lock().unwrap();
|
||||||
@@ -1208,9 +1445,19 @@ mod tests {
|
|||||||
));
|
));
|
||||||
buffer.push_notify("must persist".to_string(), false);
|
buffer.push_notify("must persist".to_string(), false);
|
||||||
|
|
||||||
let error = interceptor.pending_history_appends().await.unwrap_err();
|
let error = interceptor
|
||||||
|
.pending_history_appends(PendingHistoryAppendsContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.contains("failed to render notify_wrapper"));
|
assert!(
|
||||||
|
error
|
||||||
|
.diagnostic()
|
||||||
|
.contains("failed to render notify_wrapper")
|
||||||
|
);
|
||||||
let requeued = buffer.drain();
|
let requeued = buffer.drain();
|
||||||
assert_eq!(requeued.len(), 1);
|
assert_eq!(requeued.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1479,13 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let items = interceptor.pending_history_appends().await.unwrap();
|
let items = interceptor
|
||||||
|
.pending_history_appends(PendingHistoryAppendsContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(items.len(), 2);
|
assert_eq!(items.len(), 2);
|
||||||
let first = items[0].as_text().unwrap_or_default();
|
let first = items[0].as_text().unwrap_or_default();
|
||||||
let second = items[1].as_text().unwrap_or_default();
|
let second = items[1].as_text().unwrap_or_default();
|
||||||
@@ -1246,7 +1499,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Empty buffer → empty Vec (no synthesised items).
|
// Empty buffer → empty Vec (no synthesised items).
|
||||||
let again = interceptor.pending_history_appends().await.unwrap();
|
let again = interceptor
|
||||||
|
.pending_history_appends(PendingHistoryAppendsContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(again.is_empty());
|
assert!(again.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,7 +1528,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Continue));
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
assert_eq!(ctx.len(), 1, "pre_llm_request must not append notifies");
|
assert_eq!(ctx.len(), 1, "pre_llm_request must not append notifies");
|
||||||
@@ -1299,10 +1565,17 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Cancel(_)));
|
assert!(matches!(action, PreRequestAction::Cancel(_)));
|
||||||
assert!(first_called.load(Ordering::Relaxed));
|
assert!(first_called.load(Ordering::Relaxed));
|
||||||
assert_eq!(second_count.load(Ordering::Relaxed), 0);
|
assert_eq!(second_count.load(Ordering::Relaxed), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
|
|||||||
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
||||||
pub use runtime::dir::RuntimeDir;
|
pub use runtime::dir::RuntimeDir;
|
||||||
pub use segment_log_sink::SegmentLogSink;
|
pub use segment_log_sink::SegmentLogSink;
|
||||||
|
pub use session_capture::SessionEntryRef;
|
||||||
pub use session_history::{
|
pub use session_history::{
|
||||||
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
|
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
|
||||||
WorkerHistoryProvenance, WorkerSubjectSnapshot,
|
WorkerHistoryProvenance, WorkerSubjectSnapshot,
|
||||||
|
|||||||
@@ -45,14 +45,17 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PreToolCall> for PermissionHook {
|
impl Hook<PreToolCall> for PermissionHook {
|
||||||
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
async fn call(
|
||||||
match self.action_for(input) {
|
&self,
|
||||||
|
input: &ToolCallSummary,
|
||||||
|
) -> Result<HookPreToolAction, crate::hook::HookError> {
|
||||||
|
Ok(match self.action_for(input) {
|
||||||
ToolPermissionAction::Allow => HookPreToolAction::Continue,
|
ToolPermissionAction::Allow => HookPreToolAction::Continue,
|
||||||
ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)),
|
ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)),
|
||||||
ToolPermissionAction::Ask => {
|
ToolPermissionAction::Ask => {
|
||||||
HookPreToolAction::Deny(permission_ask_unsupported_message(input))
|
HookPreToolAction::Deny(permission_ask_unsupported_message(input))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +177,7 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
match denied {
|
match denied {
|
||||||
HookPreToolAction::Deny(message) => {
|
Ok(HookPreToolAction::Deny(message)) => {
|
||||||
assert!(message.contains("permission denied"));
|
assert!(message.contains("permission denied"));
|
||||||
assert!(message.contains("Bash"));
|
assert!(message.contains("Bash"));
|
||||||
}
|
}
|
||||||
@@ -192,7 +195,7 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
match asked {
|
match asked {
|
||||||
HookPreToolAction::Deny(message) => {
|
Ok(HookPreToolAction::Deny(message)) => {
|
||||||
assert!(message.contains("permission ask unsupported"));
|
assert!(message.contains("permission ask unsupported"));
|
||||||
assert!(message.contains("denied fail-closed"));
|
assert!(message.contains("denied fail-closed"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub(crate) struct SessionEntryRef(String);
|
pub struct SessionEntryRef(String);
|
||||||
|
|
||||||
impl SessionEntryRef {
|
impl SessionEntryRef {
|
||||||
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
|
pub fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
|
||||||
Self(format!("E{}", entry_id.0))
|
Self(format!("E{}", entry_id.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
let suffix = value.strip_prefix('E')?;
|
let suffix = value.strip_prefix('E')?;
|
||||||
if suffix.is_empty()
|
if suffix.is_empty()
|
||||||
|| suffix.len() > 64
|
|| suffix.len() > 64
|
||||||
@@ -43,7 +43,7 @@ impl SessionEntryRef {
|
|||||||
Some(Self(value.to_string()))
|
Some(Self(value.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,12 @@ impl TicketIntakeReadyShutdownHook {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
|
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
|
||||||
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
|
async fn call(
|
||||||
|
&self,
|
||||||
|
info: &ToolResultSummary,
|
||||||
|
) -> Result<HookPostToolAction, crate::hook::HookError> {
|
||||||
self.observe_tool_result(info);
|
self.observe_tool_result(info);
|
||||||
HookPostToolAction::Continue
|
Ok(HookPostToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-125
@@ -2,127 +2,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::worker::WorkspaceClient;
|
use crate::worker::WorkspaceClient;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
pub use workspace_api::{
|
||||||
#[serde(rename_all = "snake_case")]
|
SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
|
||||||
pub enum SkillDiagnosticSeverity {
|
SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus,
|
||||||
Error,
|
SkillProvenance, SkillResourceRef, SkillSourceKind,
|
||||||
Warning,
|
};
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillDiagnostic {
|
|
||||||
pub severity: SkillDiagnosticSeverity,
|
|
||||||
pub code: String,
|
|
||||||
pub message: String,
|
|
||||||
/// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub source: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SkillDiagnostic {
|
|
||||||
pub fn error(
|
|
||||||
code: impl Into<String>,
|
|
||||||
message: impl Into<String>,
|
|
||||||
source: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
severity: SkillDiagnosticSeverity::Error,
|
|
||||||
code: code.into(),
|
|
||||||
message: message.into(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn warning(
|
|
||||||
code: impl Into<String>,
|
|
||||||
message: impl Into<String>,
|
|
||||||
source: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
severity: SkillDiagnosticSeverity::Warning,
|
|
||||||
code: code.into(),
|
|
||||||
message: message.into(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SkillSourceKind {
|
|
||||||
Builtin,
|
|
||||||
Workspace,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillProvenance {
|
|
||||||
pub kind: SkillSourceKind,
|
|
||||||
/// Stable id: `builtin:<name>` or `workspace:<name>`.
|
|
||||||
pub id: String,
|
|
||||||
/// Virtual config/resource path. Never an absolute host filesystem path.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub virtual_path: Option<String>,
|
|
||||||
/// Active Workspace config revision for Workspace Skills.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub revision: Option<u64>,
|
|
||||||
/// Digest of the immutable `SKILL.md` source.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub source_digest: Option<String>,
|
|
||||||
/// Digest of the active virtual config tree snapshot.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tree_digest: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillResourceRef {
|
|
||||||
pub kind: String,
|
|
||||||
/// Skill-relative resource name/path. Never an absolute filesystem path.
|
|
||||||
pub name: String,
|
|
||||||
pub supported: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub diagnostic: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillCatalogEntry {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub provenance: SkillProvenance,
|
|
||||||
#[serde(default)]
|
|
||||||
pub overrides: Vec<SkillProvenance>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillCatalogResponse {
|
|
||||||
/// Authority label for diagnostics; callers must not interpret it as a path.
|
|
||||||
pub authority: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub entries: Vec<SkillCatalogEntry>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SkillDetailResponse {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub provenance: SkillProvenance,
|
|
||||||
#[serde(default)]
|
|
||||||
pub overrides: Vec<SkillProvenance>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
|
||||||
/// Imported Markdown content with YAML frontmatter delimiters removed.
|
|
||||||
/// This is intentionally omitted from catalog responses.
|
|
||||||
pub body: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub allowed_tools: Vec<String>,
|
|
||||||
/// Explicitly documents that allowed-tools is parsed only as an experimental hint.
|
|
||||||
pub allowed_tools_status: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub resources: Vec<SkillResourceRef>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct SkillActivationResponse {
|
pub struct SkillActivationResponse {
|
||||||
@@ -144,6 +28,8 @@ pub enum SkillClientError {
|
|||||||
Request(#[from] crate::worker::WorkspaceClientError),
|
Request(#[from] crate::worker::WorkspaceClientError),
|
||||||
#[error("Skill API response JSON is invalid: {0}")]
|
#[error("Skill API response JSON is invalid: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("Skill API response violates the shared contract: {0}")]
|
||||||
|
InvalidResponse(#[from] workspace_api::SkillApiValidationError),
|
||||||
#[error("Skill API returned HTTP {status}: {body}")]
|
#[error("Skill API returned HTTP {status}: {body}")]
|
||||||
Http {
|
Http {
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
@@ -155,11 +41,15 @@ pub enum SkillClientError {
|
|||||||
|
|
||||||
impl dyn WorkspaceClient + '_ {
|
impl dyn WorkspaceClient + '_ {
|
||||||
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
|
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
|
||||||
self.get_skill_json("skills")
|
let response: SkillCatalogResponse = self.get_skill_json("skills")?;
|
||||||
|
response.validate()?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
|
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
|
||||||
self.get_skill_json(&format!("skills/{name}"))
|
let response: SkillDetailResponse = self.get_skill_json(&format!("skills/{name}"))?;
|
||||||
|
response.validate()?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
|
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
|
||||||
@@ -229,11 +119,24 @@ mod tests {
|
|||||||
assert_eq!(worker_header, None);
|
assert_eq!(worker_header, None);
|
||||||
assert_eq!(authorization, None);
|
assert_eq!(authorization, None);
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"authority": "workspace-backend-skills-v0",
|
"authority": "workspace-config-skills-v1",
|
||||||
|
"projection": {
|
||||||
|
"config_revision": 7,
|
||||||
|
"tree_digest": "tree-digest"
|
||||||
|
},
|
||||||
"entries": [{
|
"entries": [{
|
||||||
"name": "triage-errors",
|
"name": "triage-errors",
|
||||||
"description": "Use when triaging errors.",
|
"description": "Use when triaging errors.",
|
||||||
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
|
"activation_status": "active",
|
||||||
|
"projection_status": "valid",
|
||||||
|
"provenance": {
|
||||||
|
"kind": "workspace",
|
||||||
|
"id": "workspace:triage-errors",
|
||||||
|
"virtual_path": "skills/triage-errors/SKILL.md",
|
||||||
|
"revision": 7,
|
||||||
|
"source_digest": "source-digest",
|
||||||
|
"tree_digest": "tree-digest"
|
||||||
|
},
|
||||||
"overrides": [],
|
"overrides": [],
|
||||||
"diagnostics": []
|
"diagnostics": []
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -425,6 +425,8 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
WorkerManifestConfig::resolution_defaults().merge(child_config),
|
WorkerManifestConfig::resolution_defaults().merge(child_config),
|
||||||
)
|
)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?;
|
.map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?;
|
||||||
|
bind_child_memory_settings(&self.spawner_manifest, &mut child_manifest)
|
||||||
|
.map_err(ToolError::ExecutionFailed)?;
|
||||||
// Delegated children stay bound to their scoped session and cannot use
|
// Delegated children stay bound to their scoped session and cannot use
|
||||||
// Workspace attachment tools to replace it with parent-level authority.
|
// Workspace attachment tools to replace it with parent-level authority.
|
||||||
child_manifest.feature.manage_workdir.enabled = false;
|
child_manifest.feature.manage_workdir.enabled = false;
|
||||||
@@ -827,6 +829,33 @@ fn profile_error_with_available(error: ProfileError, available: &AvailableProfil
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bind_child_memory_settings(
|
||||||
|
parent: &manifest::WorkerManifest,
|
||||||
|
child: &mut manifest::WorkerManifest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !child.feature.memory.profile.enabled {
|
||||||
|
return child
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(str::to_string);
|
||||||
|
}
|
||||||
|
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
||||||
|
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
child
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.bind_workspace_settings(workspace_settings)
|
||||||
|
.map_err(str::to_string)?;
|
||||||
|
child
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
|
fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
|
||||||
WorkerManifestConfig {
|
WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
@@ -894,7 +923,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
|||||||
model: c.model.clone(),
|
model: c.model.clone(),
|
||||||
}),
|
}),
|
||||||
web: manifest.web.clone(),
|
web: manifest.web.clone(),
|
||||||
memory: manifest.memory.clone(),
|
|
||||||
skills: manifest.skills.clone(),
|
skills: manifest.skills.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1091,10 +1119,7 @@ enabled = true
|
|||||||
thread = true
|
thread = true
|
||||||
|
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
enabled = true
|
enabled = false
|
||||||
|
|
||||||
[memory]
|
|
||||||
extract_threshold = 4000
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1526,6 +1551,33 @@ extract_threshold = 4000
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn child_memory_inherits_only_the_parents_trusted_settings_snapshot() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut parent = parent_manifest(temp.path(), None);
|
||||||
|
parent.feature.memory.profile.enabled = true;
|
||||||
|
parent
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
settings_revision: 4,
|
||||||
|
language: "日本語".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut child = parent.clone();
|
||||||
|
child.feature.memory.workspace_settings = None;
|
||||||
|
|
||||||
|
bind_child_memory_settings(&parent, &mut child).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
child.feature.memory.workspace_settings(),
|
||||||
|
parent.feature.memory.workspace_settings()
|
||||||
|
);
|
||||||
|
|
||||||
|
child.feature.memory.profile.enabled = false;
|
||||||
|
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn write_project_profile_registry(
|
fn write_project_profile_registry(
|
||||||
project: &Path,
|
project: &Path,
|
||||||
default: Option<&str>,
|
default: Option<&str>,
|
||||||
|
|||||||
+514
-1457
File diff suppressed because it is too large
Load Diff
@@ -578,138 +578,6 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: `Worker::compact()` must reset the in-memory
|
|
||||||
/// `extract_pointer` so extract keeps firing on the new compacted
|
|
||||||
/// session.
|
|
||||||
///
|
|
||||||
/// Without the reset, the pointer's `processed_through_history_len`
|
|
||||||
/// holds the old (typically large) item count, while the new compacted
|
|
||||||
/// session starts with a much shorter history (`[summary, ...]`).
|
|
||||||
/// `cumulative_input_tokens_since` would then filter every new
|
|
||||||
/// usage record out (their `history_len` is below the stale pointer)
|
|
||||||
/// and extract would never re-fire for the rest of the process.
|
|
||||||
const EXTRACT_PLUS_COMPACT_MANIFEST: &str = r#"
|
|
||||||
[worker]
|
|
||||||
name = "test-worker"
|
|
||||||
pwd = "./"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "test-model"
|
|
||||||
|
|
||||||
[engine]
|
|
||||||
max_tokens = 100
|
|
||||||
|
|
||||||
[memory]
|
|
||||||
workspace_id = "test-workspace"
|
|
||||||
settings_revision = 1
|
|
||||||
language = "English"
|
|
||||||
extract_threshold = 1
|
|
||||||
|
|
||||||
[compaction]
|
|
||||||
compact_threshold = 1
|
|
||||||
compact_retained_tokens = 0
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "./"
|
|
||||||
permission = "write"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn finish_memory_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
|
|
||||||
let input = serde_json::json!({
|
|
||||||
"staged_count": 0,
|
|
||||||
"no_candidates_reason": "test run has no durable candidates"
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
vec![
|
|
||||||
LlmEvent::tool_use_start(0, call_id, "FinishMemoryExtraction"),
|
|
||||||
LlmEvent::tool_input_delta(0, input),
|
|
||||||
LlmEvent::tool_use_stop(0),
|
|
||||||
LlmEvent::Status(StatusEvent {
|
|
||||||
status: ResponseStatus::Completed,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
|
|
||||||
// Mock LLM responses, in call order:
|
|
||||||
// [0] first run with usage(1000) so extract threshold (=1) fires.
|
|
||||||
// [1] extract worker invokes FinishMemoryExtraction with empty output.
|
|
||||||
// [2] extract worker closes after the tool result.
|
|
||||||
// [3] compact worker invokes write_summary.
|
|
||||||
// [4] compact worker closes after the tool result.
|
|
||||||
let client = MockClient::new(vec![
|
|
||||||
text_events_with_usage("hi", 1000),
|
|
||||||
finish_memory_extraction_tool_use_events("ec1"),
|
|
||||||
single_text_events("done"),
|
|
||||||
write_summary_tool_use_events("sc1", "summary"),
|
|
||||||
single_text_events("done"),
|
|
||||||
]);
|
|
||||||
let mut worker = make_worker_with_manifest(EXTRACT_PLUS_COMPACT_MANIFEST, client).await;
|
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
|
||||||
|
|
||||||
// extract fires; pointer becomes Some.
|
|
||||||
worker.try_post_run_extract().await.unwrap();
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_some(),
|
|
||||||
"extract_pointer should be Some after a successful extract"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compact runs. Without the fix the in-memory pointer would still
|
|
||||||
// reference the old Segment's history_len.
|
|
||||||
worker.try_pre_run_compact().await;
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_none(),
|
|
||||||
"extract_pointer must be reset to None after compact (matches cold-restore on the new Segment)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `extract_threshold = 0` is treated as "disabled" — without this, a
|
|
||||||
/// raw `>=` comparison against `tokens_since` would fire extract on
|
|
||||||
/// every post-run regardless of activity. Mirrors the consolidation
|
|
||||||
/// zero-threshold convention so users have a single way to opt out
|
|
||||||
/// without removing the `[memory]` section.
|
|
||||||
const EXTRACT_THRESHOLD_ZERO_MANIFEST: &str = r#"
|
|
||||||
[worker]
|
|
||||||
name = "test-worker"
|
|
||||||
pwd = "./"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "test-model"
|
|
||||||
|
|
||||||
[engine]
|
|
||||||
max_tokens = 100
|
|
||||||
|
|
||||||
[memory]
|
|
||||||
extract_threshold = 0
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "./"
|
|
||||||
permission = "write"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn extract_threshold_zero_is_disabled() {
|
|
||||||
// Mock provides exactly one response — the first run. If extract
|
|
||||||
// were treated as "fire on any change" because of `tokens_since >= 0`,
|
|
||||||
// it would call into the extract worker and exhaust the mock.
|
|
||||||
let client = MockClient::new(vec![text_events_with_usage("hi", 1000)]);
|
|
||||||
let mut worker = make_worker_with_manifest(EXTRACT_THRESHOLD_ZERO_MANIFEST, client).await;
|
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
|
||||||
worker
|
|
||||||
.try_post_run_extract()
|
|
||||||
.await
|
|
||||||
.expect("extract_threshold=0 must skip silently, not fail");
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_none(),
|
|
||||||
"no extract should have run — pointer must remain None"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
||||||
// Only the first run has a response. Compaction will run the
|
// Only the first run has a response. Compaction will run the
|
||||||
@@ -746,112 +614,6 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Detached post-run memory jobs (`spawn_post_run_memory_jobs` /
|
|
||||||
// `wait_for_memory_jobs`). Covers the detach round-trip and the structural
|
|
||||||
// invariant that the cloned memory-task Worker shares `SegmentState` with the
|
|
||||||
// source Worker, so that `save_extension` from the background extract does not
|
|
||||||
// leave the next turn's `save_user_input` looking at a stale session pointer.
|
|
||||||
|
|
||||||
const EXTRACT_NO_COMPACT_MANIFEST: &str = r#"
|
|
||||||
[worker]
|
|
||||||
name = "test-worker"
|
|
||||||
pwd = "./"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "test-model"
|
|
||||||
|
|
||||||
[engine]
|
|
||||||
max_tokens = 100
|
|
||||||
|
|
||||||
[memory]
|
|
||||||
workspace_id = "test-workspace"
|
|
||||||
settings_revision = 1
|
|
||||||
language = "English"
|
|
||||||
extract_threshold = 1
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "./"
|
|
||||||
permission = "write"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
|
|
||||||
let client = MockClient::new(vec![
|
|
||||||
text_events_with_usage("recorded", 1000),
|
|
||||||
finish_memory_extraction_tool_use_events("ec-large"),
|
|
||||||
single_text_events("done"),
|
|
||||||
]);
|
|
||||||
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
|
||||||
|
|
||||||
let large_request = format!("remember this large slice: {}", "x ".repeat(200_000));
|
|
||||||
worker.run_text(&large_request).await.unwrap();
|
|
||||||
|
|
||||||
worker.try_post_run_extract().await.expect(
|
|
||||||
"large unprocessed extract ranges must reach the extract worker, not abort locally",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_some(),
|
|
||||||
"successful extract should advance the pointer even when the input range is large"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn spawn_and_wait_drives_extract_to_completion() {
|
|
||||||
let client = MockClient::new(vec![
|
|
||||||
text_events_with_usage("hi", 1000),
|
|
||||||
finish_memory_extraction_tool_use_events("ec1"),
|
|
||||||
single_text_events("done"),
|
|
||||||
]);
|
|
||||||
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_none(),
|
|
||||||
"extract has not run yet — pointer must be None"
|
|
||||||
);
|
|
||||||
|
|
||||||
worker.spawn_post_run_memory_jobs();
|
|
||||||
worker.wait_for_memory_jobs().await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
worker.extract_pointer().is_some(),
|
|
||||||
"spawn + wait must complete extract; pointer should be set"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn detached_extract_does_not_fork_session_log() {
|
|
||||||
// Source worker and the cloned memory-task worker share `SegmentState` via
|
|
||||||
// `Arc<_>`. The detached extract advances the entry tally through
|
|
||||||
// `save_extension`; the next `run` must see that same tally so
|
|
||||||
// `ensure_head_or_fork` does not spawn a new session.
|
|
||||||
let client = MockClient::new(vec![
|
|
||||||
text_events_with_usage("hi", 1000),
|
|
||||||
finish_memory_extraction_tool_use_events("ec1"),
|
|
||||||
single_text_events("done"),
|
|
||||||
text_events_with_usage("ok", 1000),
|
|
||||||
]);
|
|
||||||
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
|
||||||
let session_before = worker.segment_id();
|
|
||||||
|
|
||||||
worker.spawn_post_run_memory_jobs();
|
|
||||||
worker.wait_for_memory_jobs().await;
|
|
||||||
|
|
||||||
worker.run_text("second").await.unwrap();
|
|
||||||
let session_after = worker.segment_id();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
session_before, session_after,
|
|
||||||
"detached extract's save_extension and the next turn's save_user_input \
|
|
||||||
must share the entry tally through SegmentState — a fork here means the \
|
|
||||||
clone carried its own counter"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn controller_compact_method_emits_start_and_done() {
|
async fn controller_compact_method_emits_start_and_done() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
|
|||||||
@@ -795,13 +795,25 @@ permission = "write"
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let mut resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(workspace.path())
|
.with_workspace_base(workspace.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
if resolved.manifest.feature.memory.enabled() {
|
||||||
|
resolved
|
||||||
|
.manifest
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: "workspace-test".to_string(),
|
||||||
|
settings_revision: 1,
|
||||||
|
language: "English".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
let workspace_context =
|
let workspace_context =
|
||||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ required-features = ["typescript"]
|
|||||||
name = "generate_memory_api_types"
|
name = "generate_memory_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_skill_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_auth_api_types"
|
name = "generate_auth_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::skill_api_typescript());
|
||||||
|
}
|
||||||
@@ -1934,6 +1934,408 @@ pub struct RepositoryAccessProjection {
|
|||||||
pub bindings: Vec<RepositorySshAccessBinding>,
|
pub bindings: Vec<RepositorySshAccessBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const SKILL_CATALOG_AUTHORITY: &str = "workspace-config-skills-v1";
|
||||||
|
pub const SKILL_API_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||||
|
pub const SKILL_API_MAX_CATALOG_ENTRIES: usize = 500;
|
||||||
|
pub const SKILL_API_MAX_OVERRIDES: usize = 64;
|
||||||
|
pub const SKILL_API_MAX_DIAGNOSTICS: usize = 100;
|
||||||
|
pub const SKILL_API_MAX_RESOURCES: usize = 500;
|
||||||
|
pub const SKILL_API_MAX_ALLOWED_TOOLS: usize = 100;
|
||||||
|
pub const SKILL_API_MAX_NAME_BYTES: usize = 128;
|
||||||
|
pub const SKILL_API_MAX_LABEL_BYTES: usize = 4_096;
|
||||||
|
pub const SKILL_API_MAX_BODY_BYTES: usize = 1_048_576;
|
||||||
|
pub const SKILL_API_MAX_PATH_BYTES: usize = 1_024;
|
||||||
|
pub const SKILL_API_MAX_DIGEST_BYTES: usize = 128;
|
||||||
|
pub const SKILL_API_MAX_RESPONSE_BYTES: usize = 2_097_152;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SkillDiagnosticSeverity {
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillDiagnostic {
|
||||||
|
pub severity: SkillDiagnosticSeverity,
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillDiagnostic {
|
||||||
|
pub fn error(
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
severity: SkillDiagnosticSeverity::Error,
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning(
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
severity: SkillDiagnosticSeverity::Warning,
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SkillSourceKind {
|
||||||
|
Builtin,
|
||||||
|
Workspace,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillProvenance {
|
||||||
|
pub kind: SkillSourceKind,
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub virtual_path: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional, type = "number"))]
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub source_digest: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub tree_digest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SkillActivationStatus {
|
||||||
|
Active,
|
||||||
|
Inactive,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SkillProjectionStatus {
|
||||||
|
Valid,
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillProjectionIdentity {
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub tree_digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillResourceRef {
|
||||||
|
pub kind: String,
|
||||||
|
pub name: String,
|
||||||
|
pub supported: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub diagnostic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillCatalogEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub activation_status: SkillActivationStatus,
|
||||||
|
pub projection_status: SkillProjectionStatus,
|
||||||
|
pub provenance: SkillProvenance,
|
||||||
|
pub overrides: Vec<SkillProvenance>,
|
||||||
|
pub diagnostics: Vec<SkillDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillCatalogResponse {
|
||||||
|
pub authority: String,
|
||||||
|
pub projection: SkillProjectionIdentity,
|
||||||
|
pub entries: Vec<SkillCatalogEntry>,
|
||||||
|
pub diagnostics: Vec<SkillDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SkillDetailResponse {
|
||||||
|
pub authority: String,
|
||||||
|
pub projection: SkillProjectionIdentity,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub provenance: SkillProvenance,
|
||||||
|
pub overrides: Vec<SkillProvenance>,
|
||||||
|
pub diagnostics: Vec<SkillDiagnostic>,
|
||||||
|
pub activation_status: SkillActivationStatus,
|
||||||
|
pub projection_status: SkillProjectionStatus,
|
||||||
|
pub body: String,
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
pub allowed_tools_status: String,
|
||||||
|
pub resources: Vec<SkillResourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SkillApiValidationError {
|
||||||
|
CollectionTooLarge,
|
||||||
|
StringTooLarge,
|
||||||
|
InvalidProjectionIdentity,
|
||||||
|
InvalidProvenance,
|
||||||
|
InvalidVirtualPath,
|
||||||
|
StaleProjection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SkillApiValidationError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::CollectionTooLarge => "Skill API collection exceeds its limit",
|
||||||
|
Self::StringTooLarge => "Skill API string exceeds its limit",
|
||||||
|
Self::InvalidProjectionIdentity => "Skill API projection identity is invalid",
|
||||||
|
Self::InvalidProvenance => "Skill API provenance is invalid",
|
||||||
|
Self::InvalidVirtualPath => "Skill API virtual path is invalid",
|
||||||
|
Self::StaleProjection => "Workspace Skill projection is stale",
|
||||||
|
};
|
||||||
|
formatter.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SkillApiValidationError {}
|
||||||
|
|
||||||
|
impl SkillProjectionIdentity {
|
||||||
|
fn validate(&self) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_safe_integer(self.config_revision)?;
|
||||||
|
validate_nonempty_string(&self.tree_digest, SKILL_API_MAX_DIGEST_BYTES)
|
||||||
|
.map_err(|_| SkillApiValidationError::InvalidProjectionIdentity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillProvenance {
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
projection: &SkillProjectionIdentity,
|
||||||
|
) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_nonempty_string(&self.id, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_optional_string(self.virtual_path.as_deref(), SKILL_API_MAX_PATH_BYTES)?;
|
||||||
|
validate_optional_string(self.source_digest.as_deref(), SKILL_API_MAX_DIGEST_BYTES)?;
|
||||||
|
validate_optional_string(self.tree_digest.as_deref(), SKILL_API_MAX_DIGEST_BYTES)?;
|
||||||
|
if let Some(revision) = self.revision {
|
||||||
|
validate_safe_integer(revision)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_prefix = match self.kind {
|
||||||
|
SkillSourceKind::Builtin => "builtin:",
|
||||||
|
SkillSourceKind::Workspace => "workspace:",
|
||||||
|
};
|
||||||
|
if !self.id.starts_with(expected_prefix)
|
||||||
|
|| self
|
||||||
|
.virtual_path
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|path| !is_virtual_path(path))
|
||||||
|
|| self.source_digest.is_none()
|
||||||
|
{
|
||||||
|
return Err(SkillApiValidationError::InvalidProvenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.kind {
|
||||||
|
SkillSourceKind::Builtin => {
|
||||||
|
if self.revision.is_some() || self.tree_digest.is_some() {
|
||||||
|
return Err(SkillApiValidationError::InvalidProvenance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SkillSourceKind::Workspace => {
|
||||||
|
let Some(revision) = self.revision else {
|
||||||
|
return Err(SkillApiValidationError::InvalidProvenance);
|
||||||
|
};
|
||||||
|
let Some(tree_digest) = self.tree_digest.as_deref() else {
|
||||||
|
return Err(SkillApiValidationError::InvalidProvenance);
|
||||||
|
};
|
||||||
|
if revision != projection.config_revision || tree_digest != projection.tree_digest {
|
||||||
|
return Err(SkillApiValidationError::StaleProjection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillCatalogEntry {
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
projection: &SkillProjectionIdentity,
|
||||||
|
) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_nonempty_string(&self.name, SKILL_API_MAX_NAME_BYTES)?;
|
||||||
|
validate_string(&self.description, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_collection(&self.overrides, SKILL_API_MAX_OVERRIDES)?;
|
||||||
|
validate_diagnostics(&self.diagnostics)?;
|
||||||
|
self.provenance.validate(projection)?;
|
||||||
|
for provenance in &self.overrides {
|
||||||
|
provenance.validate(projection)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillCatalogResponse {
|
||||||
|
pub fn validate(&self) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_nonempty_string(&self.authority, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
if self.authority != SKILL_CATALOG_AUTHORITY {
|
||||||
|
return Err(SkillApiValidationError::InvalidProjectionIdentity);
|
||||||
|
}
|
||||||
|
self.projection.validate()?;
|
||||||
|
validate_collection(&self.entries, SKILL_API_MAX_CATALOG_ENTRIES)?;
|
||||||
|
validate_diagnostics(&self.diagnostics)?;
|
||||||
|
for entry in &self.entries {
|
||||||
|
entry.validate(&self.projection)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillDetailResponse {
|
||||||
|
pub fn validate(&self) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_nonempty_string(&self.authority, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
if self.authority != SKILL_CATALOG_AUTHORITY {
|
||||||
|
return Err(SkillApiValidationError::InvalidProjectionIdentity);
|
||||||
|
}
|
||||||
|
self.projection.validate()?;
|
||||||
|
validate_nonempty_string(&self.name, SKILL_API_MAX_NAME_BYTES)?;
|
||||||
|
validate_string(&self.description, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_string(&self.body, SKILL_API_MAX_BODY_BYTES)?;
|
||||||
|
validate_strings(
|
||||||
|
&self.allowed_tools,
|
||||||
|
SKILL_API_MAX_ALLOWED_TOOLS,
|
||||||
|
SKILL_API_MAX_LABEL_BYTES,
|
||||||
|
)?;
|
||||||
|
validate_nonempty_string(&self.allowed_tools_status, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_resources(&self.resources)?;
|
||||||
|
validate_collection(&self.overrides, SKILL_API_MAX_OVERRIDES)?;
|
||||||
|
validate_diagnostics(&self.diagnostics)?;
|
||||||
|
self.provenance.validate(&self.projection)?;
|
||||||
|
for provenance in &self.overrides {
|
||||||
|
provenance.validate(&self.projection)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_safe_integer(value: u64) -> Result<(), SkillApiValidationError> {
|
||||||
|
if value <= SKILL_API_MAX_SAFE_INTEGER {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SkillApiValidationError::InvalidProjectionIdentity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_collection<T>(values: &[T], limit: usize) -> Result<(), SkillApiValidationError> {
|
||||||
|
if values.len() <= limit {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SkillApiValidationError::CollectionTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_string(value: &str, limit: usize) -> Result<(), SkillApiValidationError> {
|
||||||
|
if value.len() <= limit {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SkillApiValidationError::StringTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nonempty_string(value: &str, limit: usize) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_string(value, limit)?;
|
||||||
|
if value.is_empty() {
|
||||||
|
Err(SkillApiValidationError::StringTooLarge)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_string(
|
||||||
|
value: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<(), SkillApiValidationError> {
|
||||||
|
if let Some(value) = value {
|
||||||
|
validate_nonempty_string(value, limit)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_strings(
|
||||||
|
values: &[String],
|
||||||
|
collection_limit: usize,
|
||||||
|
string_limit: usize,
|
||||||
|
) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_collection(values, collection_limit)?;
|
||||||
|
for value in values {
|
||||||
|
validate_nonempty_string(value, string_limit)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_resources(resources: &[SkillResourceRef]) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_collection(resources, SKILL_API_MAX_RESOURCES)?;
|
||||||
|
for resource in resources {
|
||||||
|
validate_nonempty_string(&resource.kind, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_nonempty_string(&resource.name, SKILL_API_MAX_PATH_BYTES)?;
|
||||||
|
if !is_virtual_path(&resource.name) {
|
||||||
|
return Err(SkillApiValidationError::InvalidVirtualPath);
|
||||||
|
}
|
||||||
|
validate_optional_string(resource.diagnostic.as_deref(), SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_virtual_path(value: &str) -> bool {
|
||||||
|
!value.starts_with('/')
|
||||||
|
&& !value.contains('\\')
|
||||||
|
&& value
|
||||||
|
.split('/')
|
||||||
|
.all(|component| !component.is_empty() && component != "." && component != "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_diagnostics(diagnostics: &[SkillDiagnostic]) -> Result<(), SkillApiValidationError> {
|
||||||
|
validate_collection(diagnostics, SKILL_API_MAX_DIAGNOSTICS)?;
|
||||||
|
for diagnostic in diagnostics {
|
||||||
|
validate_nonempty_string(&diagnostic.code, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_nonempty_string(&diagnostic.message, SKILL_API_MAX_LABEL_BYTES)?;
|
||||||
|
validate_optional_string(diagnostic.source.as_deref(), SKILL_API_MAX_PATH_BYTES)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
pub fn catalog_typescript() -> String {
|
pub fn catalog_typescript() -> String {
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
@@ -2005,6 +2407,37 @@ pub fn repository_access_api_typescript() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn skill_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
SkillDiagnosticSeverity::decl(&config),
|
||||||
|
SkillDiagnostic::decl(&config),
|
||||||
|
SkillSourceKind::decl(&config),
|
||||||
|
SkillProvenance::decl(&config),
|
||||||
|
SkillActivationStatus::decl(&config),
|
||||||
|
SkillProjectionStatus::decl(&config),
|
||||||
|
SkillProjectionIdentity::decl(&config),
|
||||||
|
SkillResourceRef::decl(&config),
|
||||||
|
SkillCatalogEntry::decl(&config),
|
||||||
|
SkillCatalogResponse::decl(&config),
|
||||||
|
SkillDetailResponse::decl(&config),
|
||||||
|
];
|
||||||
|
let limits = format!(
|
||||||
|
"export const SKILL_API_AUTHORITY = \"{SKILL_CATALOG_AUTHORITY}\" as const;\n\nexport const SKILL_API_LIMITS = {{\n maxSafeInteger: {SKILL_API_MAX_SAFE_INTEGER},\n maxCatalogEntries: {SKILL_API_MAX_CATALOG_ENTRIES},\n maxOverrides: {SKILL_API_MAX_OVERRIDES},\n maxDiagnostics: {SKILL_API_MAX_DIAGNOSTICS},\n maxResources: {SKILL_API_MAX_RESOURCES},\n maxAllowedTools: {SKILL_API_MAX_ALLOWED_TOOLS},\n maxNameBytes: {SKILL_API_MAX_NAME_BYTES},\n maxLabelBytes: {SKILL_API_MAX_LABEL_BYTES},\n maxBodyBytes: {SKILL_API_MAX_BODY_BYTES},\n maxPathBytes: {SKILL_API_MAX_PATH_BYTES},\n maxDigestBytes: {SKILL_API_MAX_DIGEST_BYTES},\n maxResponseBytes: {SKILL_API_MAX_RESPONSE_BYTES},\n}} as const;"
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts\n\n{limits}\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
pub fn auth_api_typescript() -> String {
|
pub fn auth_api_typescript() -> String {
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
@@ -2172,6 +2605,36 @@ mod memory_typescript_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod skill_typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_skill_api_contract_is_current() {
|
||||||
|
let expected = super::skill_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/skill-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Skill API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace("=|", "=")
|
||||||
|
.replace(";}", "}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "typescript"))]
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
mod workdir_typescript_tests {
|
mod workdir_typescript_tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2205,6 +2668,145 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn skill_projection() -> SkillProjectionIdentity {
|
||||||
|
SkillProjectionIdentity {
|
||||||
|
config_revision: 42,
|
||||||
|
tree_digest: "tree-digest".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builtin_skill_provenance() -> SkillProvenance {
|
||||||
|
SkillProvenance {
|
||||||
|
kind: SkillSourceKind::Builtin,
|
||||||
|
id: "builtin:errors".to_string(),
|
||||||
|
virtual_path: Some("skills/errors/SKILL.md".to_string()),
|
||||||
|
revision: None,
|
||||||
|
source_digest: Some("builtin-source-digest".to_string()),
|
||||||
|
tree_digest: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_skill_provenance() -> SkillProvenance {
|
||||||
|
SkillProvenance {
|
||||||
|
kind: SkillSourceKind::Workspace,
|
||||||
|
id: "workspace:skills/release/SKILL.md".to_string(),
|
||||||
|
virtual_path: Some("skills/release/SKILL.md".to_string()),
|
||||||
|
revision: Some(42),
|
||||||
|
source_digest: Some("workspace-source-digest".to_string()),
|
||||||
|
tree_digest: Some("tree-digest".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_catalog_round_trips_builtin_workspace_and_invalid_projection_entries() {
|
||||||
|
let response = SkillCatalogResponse {
|
||||||
|
authority: "workspace-config-skills-v1".to_string(),
|
||||||
|
projection: skill_projection(),
|
||||||
|
entries: vec![
|
||||||
|
SkillCatalogEntry {
|
||||||
|
name: "errors".to_string(),
|
||||||
|
description: "Builtin guidance".to_string(),
|
||||||
|
activation_status: SkillActivationStatus::Active,
|
||||||
|
projection_status: SkillProjectionStatus::Valid,
|
||||||
|
provenance: builtin_skill_provenance(),
|
||||||
|
overrides: vec![],
|
||||||
|
diagnostics: vec![],
|
||||||
|
},
|
||||||
|
SkillCatalogEntry {
|
||||||
|
name: "release".to_string(),
|
||||||
|
description: "Workspace guidance".to_string(),
|
||||||
|
activation_status: SkillActivationStatus::Inactive,
|
||||||
|
projection_status: SkillProjectionStatus::Invalid,
|
||||||
|
provenance: workspace_skill_provenance(),
|
||||||
|
overrides: vec![builtin_skill_provenance()],
|
||||||
|
diagnostics: vec![SkillDiagnostic {
|
||||||
|
severity: SkillDiagnosticSeverity::Error,
|
||||||
|
code: "invalid_projection".to_string(),
|
||||||
|
message: "invalid projected Skill".to_string(),
|
||||||
|
source: Some("skills/release/SKILL.md".to_string()),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
response.validate().expect("fixture should be valid");
|
||||||
|
let json = serde_json::to_string(&response).expect("serialize Skill catalog");
|
||||||
|
let decoded: SkillCatalogResponse =
|
||||||
|
serde_json::from_str(&json).expect("deserialize Skill catalog");
|
||||||
|
assert_eq!(decoded, response);
|
||||||
|
assert!(!json.contains("\"revision\":null"));
|
||||||
|
assert!(!json.contains("\"tree_digest\":null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_detail_round_trips_shared_response() {
|
||||||
|
let response = SkillDetailResponse {
|
||||||
|
authority: "workspace-config-skills-v1".to_string(),
|
||||||
|
projection: skill_projection(),
|
||||||
|
name: "release".to_string(),
|
||||||
|
description: "Workspace guidance".to_string(),
|
||||||
|
body: "# Release\n".to_string(),
|
||||||
|
allowed_tools: vec!["Bash".to_string()],
|
||||||
|
allowed_tools_status: "experimental_hint_only".to_string(),
|
||||||
|
resources: vec![],
|
||||||
|
activation_status: SkillActivationStatus::Active,
|
||||||
|
projection_status: SkillProjectionStatus::Valid,
|
||||||
|
provenance: workspace_skill_provenance(),
|
||||||
|
overrides: vec![],
|
||||||
|
diagnostics: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
response.validate().expect("fixture should be valid");
|
||||||
|
let decoded: SkillDetailResponse = serde_json::from_value(
|
||||||
|
serde_json::to_value(&response).expect("serialize Skill detail"),
|
||||||
|
)
|
||||||
|
.expect("deserialize Skill detail");
|
||||||
|
assert_eq!(decoded, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_projection_validation_detects_stale_workspace_revision() {
|
||||||
|
let mut provenance = workspace_skill_provenance();
|
||||||
|
provenance.revision = Some(41);
|
||||||
|
let response = SkillCatalogResponse {
|
||||||
|
authority: "workspace-config-skills-v1".to_string(),
|
||||||
|
projection: skill_projection(),
|
||||||
|
entries: vec![SkillCatalogEntry {
|
||||||
|
name: "release".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
activation_status: SkillActivationStatus::Active,
|
||||||
|
projection_status: SkillProjectionStatus::Valid,
|
||||||
|
provenance,
|
||||||
|
overrides: vec![],
|
||||||
|
diagnostics: vec![],
|
||||||
|
}],
|
||||||
|
diagnostics: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.validate(),
|
||||||
|
Err(SkillApiValidationError::StaleProjection)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_dto_rejects_unknown_fields_and_unknown_provenance_kind() {
|
||||||
|
let unknown_field = serde_json::json!({
|
||||||
|
"authority": "workspace-config-skills-v1",
|
||||||
|
"projection": {"config_revision": 42, "tree_digest": "tree-digest"},
|
||||||
|
"entries": [],
|
||||||
|
"diagnostics": [],
|
||||||
|
"body": "must not be accepted"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<SkillCatalogResponse>(unknown_field).is_err());
|
||||||
|
|
||||||
|
let mut provenance =
|
||||||
|
serde_json::to_value(workspace_skill_provenance()).expect("serialize provenance");
|
||||||
|
provenance["kind"] = serde_json::Value::String("newer_source_kind".to_string());
|
||||||
|
assert!(serde_json::from_value::<SkillProvenance>(provenance).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_evidence_origins_round_trip_as_typed_provenance() {
|
fn memory_evidence_origins_round_trip_as_typed_provenance() {
|
||||||
let kinds = [
|
let kinds = [
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ fn main_config_contract_with_schema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
#[ts(export)]
|
|
||||||
pub struct WorkspaceConfigState {
|
pub struct WorkspaceConfigState {
|
||||||
pub snapshot: ConfigTreeSnapshot,
|
pub snapshot: ConfigTreeSnapshot,
|
||||||
pub contract: ToolchainContract,
|
pub contract: ToolchainContract,
|
||||||
@@ -118,7 +117,6 @@ pub struct EvaluatedConfigCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
#[ts(export)]
|
|
||||||
pub struct ConfigCommitRequest {
|
pub struct ConfigCommitRequest {
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
pub base_revision: u64,
|
pub base_revision: u64,
|
||||||
|
|||||||
@@ -7993,17 +7993,15 @@ fn start_memory_staging_consolidation(
|
|||||||
total_bytes,
|
total_bytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let reached_files = operation
|
const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
|
||||||
.threshold_files
|
const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
|
||||||
.is_some_and(|threshold| candidate_count >= threshold);
|
let reached_files = candidate_count >= CONSOLIDATION_THRESHOLD_FILES;
|
||||||
let reached_bytes = operation
|
let reached_bytes = total_bytes >= CONSOLIDATION_THRESHOLD_BYTES;
|
||||||
.threshold_bytes
|
|
||||||
.is_some_and(|threshold| total_bytes >= threshold);
|
|
||||||
if !operation.force && !reached_files && !reached_bytes {
|
if !operation.force && !reached_files && !reached_bytes {
|
||||||
return Ok(MemoryConsolidationOutput {
|
return Ok(MemoryConsolidationOutput {
|
||||||
status: "skipped_below_threshold".to_string(),
|
status: "skipped_below_threshold".to_string(),
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below configured threshold."
|
"Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below Backend policy threshold."
|
||||||
),
|
),
|
||||||
candidate_count,
|
candidate_count,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
@@ -19921,11 +19919,7 @@ mod tests {
|
|||||||
|
|
||||||
let output = match start_memory_staging_consolidation(
|
let output = match start_memory_staging_consolidation(
|
||||||
api,
|
api,
|
||||||
MemoryConsolidateStagingOperation {
|
MemoryConsolidateStagingOperation { force: true },
|
||||||
force: true,
|
|
||||||
threshold_files: None,
|
|
||||||
threshold_bytes: None,
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err(_) => panic!("unexpected ApiError from memory consolidation trigger"),
|
Err(_) => panic!("unexpected ApiError from memory consolidation trigger"),
|
||||||
@@ -19956,6 +19950,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
let below_threshold = start_memory_staging_consolidation(
|
||||||
|
api.clone(),
|
||||||
|
MemoryConsolidateStagingOperation { force: false },
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(below_threshold.status, "skipped_below_threshold");
|
||||||
|
assert_eq!(below_threshold.candidate_count, 1);
|
||||||
|
assert!(below_threshold.summary.contains("Backend policy threshold"));
|
||||||
|
|
||||||
let resolved_config_bundle = None;
|
let resolved_config_bundle = None;
|
||||||
let existing = api
|
let existing = api
|
||||||
.runtime
|
.runtime
|
||||||
@@ -20010,11 +20013,7 @@ mod tests {
|
|||||||
|
|
||||||
let second = match start_memory_staging_consolidation(
|
let second = match start_memory_staging_consolidation(
|
||||||
api.clone(),
|
api.clone(),
|
||||||
MemoryConsolidateStagingOperation {
|
MemoryConsolidateStagingOperation { force: true },
|
||||||
force: true,
|
|
||||||
threshold_files: None,
|
|
||||||
threshold_bytes: None,
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err(_) => panic!("unexpected ApiError from second memory consolidation trigger"),
|
Err(_) => panic!("unexpected ApiError from second memory consolidation trigger"),
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ use config_source::{
|
|||||||
ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document,
|
ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use worker::skill::{
|
use worker::skill::SkillActivationResponse;
|
||||||
SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
|
use workspace_api::{
|
||||||
SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind,
|
SKILL_CATALOG_AUTHORITY, SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse,
|
||||||
|
SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity,
|
||||||
|
SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config_source::{
|
use crate::config_source::{
|
||||||
@@ -19,7 +21,6 @@ const BUILTIN_SKILL_VIRTUAL_PATH: &str = "builtin/skills/agent-skills/SKILL.md";
|
|||||||
const SKILL_SCHEMA_PROVIDER_ID: &str = "builtin:skills";
|
const SKILL_SCHEMA_PROVIDER_ID: &str = "builtin:skills";
|
||||||
const SKILL_SCHEMA_NAMESPACE: &str = "skills";
|
const SKILL_SCHEMA_NAMESPACE: &str = "skills";
|
||||||
const SKILL_SCHEMA_VERSION: &str = "1";
|
const SKILL_SCHEMA_VERSION: &str = "1";
|
||||||
const SKILL_CATALOG_AUTHORITY: &str = "workspace-config-skills-v1";
|
|
||||||
|
|
||||||
/// Skill documents are values imported from `SKILL.md`. Known Agent Skills
|
/// Skill documents are values imported from `SKILL.md`. Known Agent Skills
|
||||||
/// frontmatter is typed while extension keys remain concrete values.
|
/// frontmatter is typed while extension keys remain concrete values.
|
||||||
@@ -101,32 +102,54 @@ pub fn catalog(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, Ski
|
|||||||
.into_values()
|
.into_values()
|
||||||
.map(|skill| skill.catalog_entry())
|
.map(|skill| skill.catalog_entry())
|
||||||
.collect();
|
.collect();
|
||||||
Ok(SkillCatalogResponse {
|
let response = SkillCatalogResponse {
|
||||||
authority: SKILL_CATALOG_AUTHORITY.to_string(),
|
authority: SKILL_CATALOG_AUTHORITY.to_string(),
|
||||||
|
projection: projection_identity(state),
|
||||||
entries,
|
entries,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
})
|
};
|
||||||
|
response
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| SkillError::InvalidProjection(error.to_string()))?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillError> {
|
pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillError> {
|
||||||
catalog(state)
|
catalog(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn projection_identity(state: &WorkspaceConfigState) -> SkillProjectionIdentity {
|
||||||
|
SkillProjectionIdentity {
|
||||||
|
config_revision: state.snapshot.revision,
|
||||||
|
tree_digest: state.snapshot.digest.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn detail(state: &WorkspaceConfigState, name: &str) -> Result<SkillDetailResponse, SkillError> {
|
pub fn detail(state: &WorkspaceConfigState, name: &str) -> Result<SkillDetailResponse, SkillError> {
|
||||||
let skill = merged_skills(state)?
|
let skill = merged_skills(state)?
|
||||||
.remove(name)
|
.remove(name)
|
||||||
.ok_or_else(|| SkillError::NotFound(name.to_string()))?;
|
.ok_or_else(|| SkillError::NotFound(name.to_string()))?;
|
||||||
Ok(SkillDetailResponse {
|
let activation_status = skill.activation_status();
|
||||||
|
let projection_status = skill.projection_status();
|
||||||
|
let response = SkillDetailResponse {
|
||||||
|
authority: SKILL_CATALOG_AUTHORITY.to_string(),
|
||||||
|
projection: projection_identity(state),
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
provenance: skill.provenance,
|
provenance: skill.provenance,
|
||||||
overrides: skill.overrides,
|
overrides: skill.overrides,
|
||||||
diagnostics: skill.diagnostics,
|
diagnostics: skill.diagnostics,
|
||||||
|
activation_status,
|
||||||
|
projection_status,
|
||||||
body: skill.body,
|
body: skill.body,
|
||||||
allowed_tools: skill.allowed_tools,
|
allowed_tools: skill.allowed_tools,
|
||||||
allowed_tools_status: "experimental_hint_only".to_string(),
|
allowed_tools_status: "experimental_hint_only".to_string(),
|
||||||
resources: skill.resources,
|
resources: skill.resources,
|
||||||
})
|
};
|
||||||
|
response
|
||||||
|
.validate()
|
||||||
|
.map_err(|error| SkillError::InvalidProjection(error.to_string()))?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activation(
|
pub fn activation(
|
||||||
@@ -415,10 +438,28 @@ impl ParsedSkill {
|
|||||||
.any(|diagnostic| diagnostic.severity == SkillDiagnosticSeverity::Error)
|
.any(|diagnostic| diagnostic.severity == SkillDiagnosticSeverity::Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn activation_status(&self) -> SkillActivationStatus {
|
||||||
|
if self.has_errors() {
|
||||||
|
SkillActivationStatus::Inactive
|
||||||
|
} else {
|
||||||
|
SkillActivationStatus::Active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projection_status(&self) -> SkillProjectionStatus {
|
||||||
|
if self.has_errors() {
|
||||||
|
SkillProjectionStatus::Invalid
|
||||||
|
} else {
|
||||||
|
SkillProjectionStatus::Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn catalog_entry(&self) -> SkillCatalogEntry {
|
fn catalog_entry(&self) -> SkillCatalogEntry {
|
||||||
SkillCatalogEntry {
|
SkillCatalogEntry {
|
||||||
name: self.name.clone(),
|
name: self.name.clone(),
|
||||||
description: self.description.clone(),
|
description: self.description.clone(),
|
||||||
|
activation_status: self.activation_status(),
|
||||||
|
projection_status: self.projection_status(),
|
||||||
provenance: self.provenance.clone(),
|
provenance: self.provenance.clone(),
|
||||||
overrides: self.overrides.clone(),
|
overrides: self.overrides.clone(),
|
||||||
diagnostics: self.diagnostics.clone(),
|
diagnostics: self.diagnostics.clone(),
|
||||||
@@ -513,12 +554,20 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
|
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
|
||||||
assert_eq!(item.provenance.revision, Some(9));
|
assert_eq!(item.provenance.revision, Some(9));
|
||||||
|
assert_eq!(catalog.projection.config_revision, 9);
|
||||||
|
assert_eq!(catalog.projection.tree_digest, state.snapshot.digest);
|
||||||
|
assert_eq!(item.activation_status, SkillActivationStatus::Active);
|
||||||
|
assert_eq!(item.projection_status, SkillProjectionStatus::Valid);
|
||||||
assert!(
|
assert!(
|
||||||
item.diagnostics
|
item.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.all(|diagnostic| diagnostic.severity != SkillDiagnosticSeverity::Error)
|
.all(|diagnostic| diagnostic.severity != SkillDiagnosticSeverity::Error)
|
||||||
);
|
);
|
||||||
let detail = detail(&state, "debug-rust").unwrap();
|
let detail = detail(&state, "debug-rust").unwrap();
|
||||||
|
assert_eq!(detail.authority, SKILL_CATALOG_AUTHORITY);
|
||||||
|
assert_eq!(detail.projection.config_revision, 9);
|
||||||
|
assert_eq!(detail.activation_status, SkillActivationStatus::Active);
|
||||||
|
assert_eq!(detail.projection_status, SkillProjectionStatus::Valid);
|
||||||
assert_eq!(detail.body, "# Debug Rust\n");
|
assert_eq!(detail.body, "# Debug Rust\n");
|
||||||
assert_eq!(detail.allowed_tools, vec!["Read", "Grep"]);
|
assert_eq!(detail.allowed_tools, vec!["Read", "Grep"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -579,6 +628,8 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|item| item.name == "debug-rust")
|
.find(|item| item.name == "debug-rust")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(item.activation_status, SkillActivationStatus::Inactive);
|
||||||
|
assert_eq!(item.projection_status, SkillProjectionStatus::Invalid);
|
||||||
assert!(
|
assert!(
|
||||||
item.diagnostics
|
item.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+27
-41
@@ -222,55 +222,41 @@ permission = "write"
|
|||||||
# # ref = "anthropic/claude-haiku-4-5"
|
# # ref = "anthropic/claude-haiku-4-5"
|
||||||
|
|
||||||
|
|
||||||
# ===== [memory] =============================================================
|
# ===== [feature.memory] ======================================================
|
||||||
# Memory subsystem の opt-in。
|
# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では
|
||||||
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、
|
# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを
|
||||||
# `<workspace>/memory/` と `<workspace>/`
|
# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが
|
||||||
# の通常 write を Worker 自体に対して deny する。
|
# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。
|
||||||
# - セクションが *無い* … 何も起きない (legacy 動作)。
|
# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる
|
||||||
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。
|
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。
|
||||||
# [memory]
|
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: Worker の pwd (構築時)。
|
# [feature.memory.profile]
|
||||||
# # 必ず絶対パス (相対なら manifest base 起点で resolve)。
|
# enabled = true
|
||||||
# workspace_root = "/abs/path/to/workspace"
|
# staging_tools = false
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: tool 側既定 = 20。
|
# [feature.memory.profile.resident]
|
||||||
# # MemoryQuery / MemoryQuery が 1 回に返す最大件数。
|
# inject_summary = true
|
||||||
# query_result_limit = 20
|
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: tool 側既定 = 3。
|
# [feature.memory.profile.extraction]
|
||||||
# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。
|
# enabled = true
|
||||||
# query_excerpt_lines = 3
|
# threshold = 30000
|
||||||
|
# worker_max_turns = 8
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
|
# # 任意。省略時はmain modelをcloneする。
|
||||||
# # extract ワーカーのモデル ([model] と同じ形式)。
|
# # [feature.memory.profile.extraction.model]
|
||||||
# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。
|
|
||||||
# # [memory.extract_model]
|
|
||||||
# # ref = "anthropic/claude-haiku-4-5"
|
# # ref = "anthropic/claude-haiku-4-5"
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: なし (extract 自動発火を完全停止)。
|
# [feature.memory.profile.consolidation]
|
||||||
# # 前回 extract pointer 以降の累積入力 token がこの値を超えると extract 起動。
|
# request_enabled = true
|
||||||
# # ※ memory tools と resident injection は extract_threshold が None でも動く。
|
|
||||||
# extract_threshold = 30000
|
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: 8 (`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`)。
|
# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。
|
||||||
# # extract worker 自身の tool loop 上限。Rust config で None の場合のみ無制限。
|
# # [feature.memory.workspace_settings]
|
||||||
# extract_worker_max_turns = 8
|
# # workspace_id = "workspace-id"
|
||||||
|
# # settings_revision = 1
|
||||||
|
# # language = "日本語"
|
||||||
#
|
#
|
||||||
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
|
# Query結果/抜粋の上限とconsolidation eligibility/thresholdはoperation/Backend
|
||||||
# # consolidation ワーカーのモデル。reasoning クラス推奨。
|
# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。
|
||||||
# # [memory.consolidation_model]
|
|
||||||
# # ref = "anthropic/claude-sonnet-4-6"
|
|
||||||
#
|
|
||||||
# # 任意。デフォルト: なし。
|
|
||||||
# # `_staging/` のエントリ数がこの値以上で consolidation 発火 (files / bytes は OR)。
|
|
||||||
# consolidation_threshold_files = 50
|
|
||||||
#
|
|
||||||
# # 任意。デフォルト: なし。
|
|
||||||
# # `_staging/` の総バイト数がこの値以上で consolidation 発火 (files / bytes は OR)。
|
|
||||||
# # files / bytes の両方が None だと consolidation 完全無効。
|
|
||||||
# consolidation_threshold_bytes = 1048576
|
|
||||||
|
|
||||||
|
|
||||||
# ===== [skills] =============================================================
|
# ===== [skills] =============================================================
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ compaction = {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = true; };
|
memory = {
|
||||||
|
enabled = true;
|
||||||
|
resident = { inject_summary = true; };
|
||||||
|
extraction = {
|
||||||
|
enabled = true;
|
||||||
|
threshold = 50000;
|
||||||
|
};
|
||||||
|
consolidation = { request_enabled = true; };
|
||||||
|
};
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
image = { enabled = true; };
|
image = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
@@ -40,12 +48,6 @@ feature = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
memory = {
|
|
||||||
extract_threshold = 50000;
|
|
||||||
consolidation_threshold_files = 5;
|
|
||||||
consolidation_threshold_bytes = 50000;
|
|
||||||
};
|
|
||||||
|
|
||||||
web = {
|
web = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
search = {
|
search = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = true; };
|
task = { enabled = true; };
|
||||||
memory = { enabled = false; staging = false; };
|
memory = { enabled = false; staging_tools = false; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
image = { enabled = true; };
|
image = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "./base.dcdl" // {
|
|||||||
|
|
||||||
feature = {
|
feature = {
|
||||||
task = { enabled = false; };
|
task = { enabled = false; };
|
||||||
memory = { enabled = true; staging = true; };
|
memory = { enabled = true; staging_tools = true; };
|
||||||
web = { enabled = false; };
|
web = { enabled = false; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Generated from workspace-api. Do not edit by hand.
|
||||||
|
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts
|
||||||
|
|
||||||
|
export const SKILL_API_AUTHORITY = "workspace-config-skills-v1" as const;
|
||||||
|
|
||||||
|
export const SKILL_API_LIMITS = {
|
||||||
|
maxSafeInteger: 9007199254740991,
|
||||||
|
maxCatalogEntries: 500,
|
||||||
|
maxOverrides: 64,
|
||||||
|
maxDiagnostics: 100,
|
||||||
|
maxResources: 500,
|
||||||
|
maxAllowedTools: 100,
|
||||||
|
maxNameBytes: 128,
|
||||||
|
maxLabelBytes: 4096,
|
||||||
|
maxBodyBytes: 1048576,
|
||||||
|
maxPathBytes: 1024,
|
||||||
|
maxDigestBytes: 128,
|
||||||
|
maxResponseBytes: 2097152,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SkillDiagnosticSeverity = "error" | "warning";
|
||||||
|
|
||||||
|
export type SkillDiagnostic = {
|
||||||
|
severity: SkillDiagnosticSeverity;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillSourceKind = "builtin" | "workspace";
|
||||||
|
|
||||||
|
export type SkillProvenance = {
|
||||||
|
kind: SkillSourceKind;
|
||||||
|
id: string;
|
||||||
|
virtual_path?: string;
|
||||||
|
revision?: number;
|
||||||
|
source_digest?: string;
|
||||||
|
tree_digest?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillActivationStatus = "active" | "inactive";
|
||||||
|
|
||||||
|
export type SkillProjectionStatus = "valid" | "invalid";
|
||||||
|
|
||||||
|
export type SkillProjectionIdentity = {
|
||||||
|
config_revision: number;
|
||||||
|
tree_digest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillResourceRef = {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
supported: boolean;
|
||||||
|
diagnostic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillCatalogEntry = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
activation_status: SkillActivationStatus;
|
||||||
|
projection_status: SkillProjectionStatus;
|
||||||
|
provenance: SkillProvenance;
|
||||||
|
overrides: Array<SkillProvenance>;
|
||||||
|
diagnostics: Array<SkillDiagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillCatalogResponse = {
|
||||||
|
authority: string;
|
||||||
|
projection: SkillProjectionIdentity;
|
||||||
|
entries: Array<SkillCatalogEntry>;
|
||||||
|
diagnostics: Array<SkillDiagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillDetailResponse = {
|
||||||
|
authority: string;
|
||||||
|
projection: SkillProjectionIdentity;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
provenance: SkillProvenance;
|
||||||
|
overrides: Array<SkillProvenance>;
|
||||||
|
diagnostics: Array<SkillDiagnostic>;
|
||||||
|
activation_status: SkillActivationStatus;
|
||||||
|
projection_status: SkillProjectionStatus;
|
||||||
|
body: string;
|
||||||
|
allowed_tools: Array<string>;
|
||||||
|
allowed_tools_status: string;
|
||||||
|
resources: Array<SkillResourceRef>;
|
||||||
|
};
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
loadWorkspaceSkillCatalog,
|
loadWorkspaceSkillCatalog,
|
||||||
|
loadWorkspaceSkillDetail,
|
||||||
workspaceApiPath,
|
workspaceApiPath,
|
||||||
workspaceRoute,
|
workspaceRoute,
|
||||||
workspaceSkillActivationPath,
|
workspaceSkillActivationPath,
|
||||||
workspaceSkillCatalogPath,
|
workspaceSkillCatalogPath,
|
||||||
workspaceSkillDetailPath,
|
workspaceSkillDetailPath,
|
||||||
} from "./http.ts";
|
} from "./http.ts";
|
||||||
|
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
@@ -89,11 +91,24 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
|||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
authority: "workspace-backend-skills-v0",
|
authority: "workspace-config-skills-v1",
|
||||||
|
projection: {
|
||||||
|
config_revision: 7,
|
||||||
|
tree_digest: "tree-digest",
|
||||||
|
},
|
||||||
entries: [{
|
entries: [{
|
||||||
name: "triage-errors",
|
name: "triage-errors",
|
||||||
description: "Use when triaging errors.",
|
description: "Use when triaging errors.",
|
||||||
provenance: { kind: "workspace", id: "workspace:triage-errors" },
|
activation_status: "active",
|
||||||
|
projection_status: "valid",
|
||||||
|
provenance: {
|
||||||
|
kind: "workspace",
|
||||||
|
id: "workspace:triage-errors",
|
||||||
|
virtual_path: "skills/triage-errors/SKILL.md",
|
||||||
|
revision: 7,
|
||||||
|
source_digest: "source-digest",
|
||||||
|
tree_digest: "tree-digest",
|
||||||
|
},
|
||||||
overrides: [],
|
overrides: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}],
|
}],
|
||||||
@@ -110,3 +125,44 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
|
|||||||
assertEquals(result.data?.entries[0].name, "triage-errors");
|
assertEquals(result.data?.entries[0].name, "triage-errors");
|
||||||
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
|
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill loaders redact and bound non-success response diagnostics", async () => {
|
||||||
|
const secret = "SENSITIVE-SKILL-BODY-CONTENT".repeat(300);
|
||||||
|
const result = await loadWorkspaceSkillCatalog(
|
||||||
|
(() =>
|
||||||
|
Promise.resolve(new Response(secret, { status: 500 }))) as typeof fetch,
|
||||||
|
"ws-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(result.data, null);
|
||||||
|
assertEquals(result.error, "Skill API request failed with HTTP 500");
|
||||||
|
assert(
|
||||||
|
!result.error?.includes(secret.slice(0, 64)),
|
||||||
|
"Skill API diagnostic must not expose response body content",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
(result.error?.length ?? 0) <= 256,
|
||||||
|
"Skill API diagnostic must remain bounded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill loaders stop reading success responses above the wire byte limit", async () => {
|
||||||
|
const oversized = `{"body":"${
|
||||||
|
"x".repeat(SKILL_API_LIMITS.maxResponseBytes + 1)
|
||||||
|
}"}`;
|
||||||
|
const result = await loadWorkspaceSkillDetail(
|
||||||
|
(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(oversized, { status: 200 }),
|
||||||
|
)) as typeof fetch,
|
||||||
|
"ws-1",
|
||||||
|
"release",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(result.data, null);
|
||||||
|
assertEquals(result.error, "Skill API response exceeds its byte limit");
|
||||||
|
assert(
|
||||||
|
!result.error?.includes(oversized.slice(0, 64)),
|
||||||
|
"Skill API diagnostic must not expose oversized response content",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,58 +1,32 @@
|
|||||||
|
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
|
||||||
|
import type {
|
||||||
|
SkillCatalogResponse,
|
||||||
|
SkillDetailResponse,
|
||||||
|
} from "$lib/generated/skill-api.ts";
|
||||||
|
import {
|
||||||
|
parseSkillCatalogResponse,
|
||||||
|
parseSkillDetailResponse,
|
||||||
|
SkillApiContractError,
|
||||||
|
} from "$lib/workspace/skills/api.ts";
|
||||||
|
|
||||||
export type ApiResult<T> = {
|
export type ApiResult<T> = {
|
||||||
data: T | null;
|
data: T | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillDiagnosticSeverity = "error" | "warning";
|
export type { SkillCatalogResponse, SkillDetailResponse };
|
||||||
|
|
||||||
export type SkillDiagnostic = {
|
type JsonLoadPolicy = {
|
||||||
severity: SkillDiagnosticSeverity;
|
diagnosticLabel: string;
|
||||||
code: string;
|
maxResponseBytes: number;
|
||||||
message: string;
|
|
||||||
source?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillProvenance = {
|
const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
|
||||||
kind: "builtin" | "workspace";
|
diagnosticLabel: "Skill API",
|
||||||
id: string;
|
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
|
||||||
virtual_path?: string;
|
|
||||||
revision?: number;
|
|
||||||
source_digest?: string;
|
|
||||||
tree_digest?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillCatalogEntry = {
|
class ResponseByteLimitError extends Error {}
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
provenance: SkillProvenance;
|
|
||||||
overrides: SkillProvenance[];
|
|
||||||
diagnostics: SkillDiagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SkillCatalogResponse = {
|
|
||||||
authority: string;
|
|
||||||
entries: SkillCatalogEntry[];
|
|
||||||
diagnostics: SkillDiagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SkillResourceRef = {
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
supported: boolean;
|
|
||||||
diagnostic?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SkillDetailResponse = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
provenance: SkillProvenance;
|
|
||||||
overrides: SkillProvenance[];
|
|
||||||
diagnostics: SkillDiagnostic[];
|
|
||||||
body: string;
|
|
||||||
allowed_tools: string[];
|
|
||||||
allowed_tools_status: string;
|
|
||||||
resources: SkillResourceRef[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
function normalizePath(path: string): string {
|
||||||
if (!path || path === "/") return "";
|
if (!path || path === "/") return "";
|
||||||
@@ -95,6 +69,9 @@ export async function loadWorkspaceSkillCatalog(
|
|||||||
return loadJson<SkillCatalogResponse>(
|
return loadJson<SkillCatalogResponse>(
|
||||||
fetchFn,
|
fetchFn,
|
||||||
workspaceSkillCatalogPath(workspaceId),
|
workspaceSkillCatalogPath(workspaceId),
|
||||||
|
undefined,
|
||||||
|
parseSkillCatalogResponse,
|
||||||
|
SKILL_API_LOAD_POLICY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +83,9 @@ export async function loadWorkspaceSkillDetail(
|
|||||||
return loadJson<SkillDetailResponse>(
|
return loadJson<SkillDetailResponse>(
|
||||||
fetchFn,
|
fetchFn,
|
||||||
workspaceSkillDetailPath(workspaceId, name),
|
workspaceSkillDetailPath(workspaceId, name),
|
||||||
|
undefined,
|
||||||
|
parseSkillDetailResponse,
|
||||||
|
SKILL_API_LOAD_POLICY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,19 +94,38 @@ export async function loadJson<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
parse: (value: unknown) => T = (value) => value as T,
|
parse: (value: unknown) => T = (value) => value as T,
|
||||||
|
policy?: JsonLoadPolicy,
|
||||||
): Promise<ApiResult<T>> {
|
): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchFn(path, init);
|
const response = await fetchFn(path, init);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (policy) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error:
|
||||||
|
`${policy.diagnosticLabel} request failed with HTTP ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
error: text || `${path} request failed (${response.status})`,
|
error: text || `${path} request failed (${response.status})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const payload: unknown = await response.json();
|
const payload: unknown = policy
|
||||||
|
? await readBoundedJson(response, policy.maxResponseBytes)
|
||||||
|
: await response.json();
|
||||||
return { data: parse(payload), error: null };
|
return { data: parse(payload), error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (policy) {
|
||||||
|
const diagnostic = error instanceof SkillApiContractError
|
||||||
|
? error.message
|
||||||
|
: error instanceof ResponseByteLimitError
|
||||||
|
? `${policy.diagnosticLabel} response exceeds its byte limit`
|
||||||
|
: `${policy.diagnosticLabel} response is invalid`;
|
||||||
|
return { data: null, error: diagnostic.slice(0, 256) };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
error: error instanceof Error ? error.message : `${path} request failed`,
|
error: error instanceof Error ? error.message : `${path} request failed`,
|
||||||
@@ -134,6 +133,50 @@ export async function loadJson<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readBoundedJson(
|
||||||
|
response: Response,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
if (contentLength !== null) {
|
||||||
|
const parsedLength = Number(contentLength);
|
||||||
|
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new ResponseByteLimitError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("response body is unavailable");
|
||||||
|
}
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
totalBytes += value.byteLength;
|
||||||
|
if (totalBytes > maxBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new ResponseByteLimitError();
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(totalBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
async function requireJson<T>(response: Response, path: string): Promise<T> {
|
async function requireJson<T>(response: Response, path: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async (
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes("<ComposerInput") &&
|
consolePage.includes("<ComposerInput") &&
|
||||||
consolePage.includes('<div class="composer-input-shell">') &&
|
consolePage.includes('class="composer-input-shell"') &&
|
||||||
!consolePage.includes("handleComposerShellClick") &&
|
!consolePage.includes("handleComposerShellClick") &&
|
||||||
consolePage.includes("bind:this={composerInputElement}") &&
|
consolePage.includes("bind:this={composerInputElement}") &&
|
||||||
consolePage.includes("onchange={handleComposerChange}") &&
|
consolePage.includes("onchange={handleComposerChange}") &&
|
||||||
@@ -616,7 +616,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
|||||||
consolePage.includes("if (!composerEditable) return") &&
|
consolePage.includes("if (!composerEditable) return") &&
|
||||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||||
composerInput.includes("preserveExactText = false") &&
|
composerInput.includes("preserveExactText = false") &&
|
||||||
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
|
consolePage.includes("const command = buildComposerSegmentsRequest(") &&
|
||||||
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
||||||
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
||||||
consolePage.includes("switchComposerTarget(target)") &&
|
consolePage.includes("switchComposerTarget(target)") &&
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import {
|
||||||
|
SKILL_API_AUTHORITY,
|
||||||
|
SKILL_API_LIMITS,
|
||||||
|
type SkillActivationStatus,
|
||||||
|
type SkillCatalogEntry,
|
||||||
|
type SkillCatalogResponse,
|
||||||
|
type SkillDetailResponse,
|
||||||
|
type SkillDiagnostic,
|
||||||
|
type SkillDiagnosticSeverity,
|
||||||
|
type SkillProjectionIdentity,
|
||||||
|
type SkillProjectionStatus,
|
||||||
|
type SkillProvenance,
|
||||||
|
type SkillResourceRef,
|
||||||
|
type SkillSourceKind,
|
||||||
|
} from "$lib/generated/skill-api.ts";
|
||||||
|
|
||||||
|
export class SkillApiContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SkillApiContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSkillCatalogResponse(
|
||||||
|
value: unknown,
|
||||||
|
): SkillCatalogResponse {
|
||||||
|
const record = strictObject(value, [
|
||||||
|
"authority",
|
||||||
|
"projection",
|
||||||
|
"entries",
|
||||||
|
"diagnostics",
|
||||||
|
], "Skill catalog response");
|
||||||
|
const authority = boundedString(
|
||||||
|
record.authority,
|
||||||
|
"Skill catalog authority",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (authority !== SKILL_API_AUTHORITY) {
|
||||||
|
throw contractError("unsupported Skill catalog authority");
|
||||||
|
}
|
||||||
|
const projection = parseProjection(record.projection);
|
||||||
|
return {
|
||||||
|
authority,
|
||||||
|
projection,
|
||||||
|
entries: boundedArray(
|
||||||
|
record.entries,
|
||||||
|
"Skill catalog entries",
|
||||||
|
SKILL_API_LIMITS.maxCatalogEntries,
|
||||||
|
).map((entry) => parseCatalogEntry(entry, projection)),
|
||||||
|
diagnostics: parseDiagnostics(record.diagnostics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSkillDetailResponse(value: unknown): SkillDetailResponse {
|
||||||
|
const record = strictObject(value, [
|
||||||
|
"authority",
|
||||||
|
"projection",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"provenance",
|
||||||
|
"overrides",
|
||||||
|
"diagnostics",
|
||||||
|
"activation_status",
|
||||||
|
"projection_status",
|
||||||
|
"body",
|
||||||
|
"allowed_tools",
|
||||||
|
"allowed_tools_status",
|
||||||
|
"resources",
|
||||||
|
], "Skill detail response");
|
||||||
|
const authority = boundedString(
|
||||||
|
record.authority,
|
||||||
|
"Skill detail authority",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (authority !== SKILL_API_AUTHORITY) {
|
||||||
|
throw contractError("unsupported Skill detail authority");
|
||||||
|
}
|
||||||
|
const projection = parseProjection(record.projection);
|
||||||
|
return {
|
||||||
|
authority,
|
||||||
|
projection,
|
||||||
|
name: boundedString(
|
||||||
|
record.name,
|
||||||
|
"Skill name",
|
||||||
|
SKILL_API_LIMITS.maxNameBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
description: boundedString(
|
||||||
|
record.description,
|
||||||
|
"Skill description",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
provenance: parseProvenance(record.provenance, projection),
|
||||||
|
overrides: parseProvenances(record.overrides, projection),
|
||||||
|
diagnostics: parseDiagnostics(record.diagnostics),
|
||||||
|
activation_status: activationStatus(record.activation_status),
|
||||||
|
projection_status: projectionStatus(record.projection_status),
|
||||||
|
body: boundedString(
|
||||||
|
record.body,
|
||||||
|
"Skill body",
|
||||||
|
SKILL_API_LIMITS.maxBodyBytes,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
allowed_tools: boundedArray(
|
||||||
|
record.allowed_tools,
|
||||||
|
"Skill allowed tools",
|
||||||
|
SKILL_API_LIMITS.maxAllowedTools,
|
||||||
|
).map((tool) =>
|
||||||
|
boundedString(
|
||||||
|
tool,
|
||||||
|
"Skill allowed tool",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
allowed_tools_status: boundedString(
|
||||||
|
record.allowed_tools_status,
|
||||||
|
"Skill allowed-tools status",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
resources: boundedArray(
|
||||||
|
record.resources,
|
||||||
|
"Skill resources",
|
||||||
|
SKILL_API_LIMITS.maxResources,
|
||||||
|
).map(parseResource),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCatalogEntry(
|
||||||
|
value: unknown,
|
||||||
|
projection: SkillProjectionIdentity,
|
||||||
|
): SkillCatalogEntry {
|
||||||
|
const record = strictObject(value, [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"activation_status",
|
||||||
|
"projection_status",
|
||||||
|
"provenance",
|
||||||
|
"overrides",
|
||||||
|
"diagnostics",
|
||||||
|
], "Skill catalog entry");
|
||||||
|
return {
|
||||||
|
name: boundedString(
|
||||||
|
record.name,
|
||||||
|
"Skill name",
|
||||||
|
SKILL_API_LIMITS.maxNameBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
description: boundedString(
|
||||||
|
record.description,
|
||||||
|
"Skill description",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
activation_status: activationStatus(record.activation_status),
|
||||||
|
projection_status: projectionStatus(record.projection_status),
|
||||||
|
provenance: parseProvenance(record.provenance, projection),
|
||||||
|
overrides: parseProvenances(record.overrides, projection),
|
||||||
|
diagnostics: parseDiagnostics(record.diagnostics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProjection(value: unknown): SkillProjectionIdentity {
|
||||||
|
const record = strictObject(
|
||||||
|
value,
|
||||||
|
["config_revision", "tree_digest"],
|
||||||
|
"Skill projection identity",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
config_revision: safeInteger(
|
||||||
|
record.config_revision,
|
||||||
|
"Skill config revision",
|
||||||
|
),
|
||||||
|
tree_digest: boundedString(
|
||||||
|
record.tree_digest,
|
||||||
|
"Skill tree digest",
|
||||||
|
SKILL_API_LIMITS.maxDigestBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProvenances(
|
||||||
|
value: unknown,
|
||||||
|
projection: SkillProjectionIdentity,
|
||||||
|
): SkillProvenance[] {
|
||||||
|
return boundedArray(
|
||||||
|
value,
|
||||||
|
"Skill overrides",
|
||||||
|
SKILL_API_LIMITS.maxOverrides,
|
||||||
|
).map((provenance) => parseProvenance(provenance, projection));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProvenance(
|
||||||
|
value: unknown,
|
||||||
|
projection: SkillProjectionIdentity,
|
||||||
|
): SkillProvenance {
|
||||||
|
const record = strictObject(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
"kind",
|
||||||
|
"id",
|
||||||
|
"virtual_path",
|
||||||
|
"revision",
|
||||||
|
"source_digest",
|
||||||
|
"tree_digest",
|
||||||
|
],
|
||||||
|
"Skill provenance",
|
||||||
|
[
|
||||||
|
"virtual_path",
|
||||||
|
"revision",
|
||||||
|
"source_digest",
|
||||||
|
"tree_digest",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const kind = sourceKind(record.kind);
|
||||||
|
const id = boundedString(
|
||||||
|
record.id,
|
||||||
|
"Skill provenance id",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const virtualPath = optionalBoundedString(
|
||||||
|
record.virtual_path,
|
||||||
|
"Skill virtual path",
|
||||||
|
SKILL_API_LIMITS.maxPathBytes,
|
||||||
|
);
|
||||||
|
const sourceDigest = optionalBoundedString(
|
||||||
|
record.source_digest,
|
||||||
|
"Skill source digest",
|
||||||
|
SKILL_API_LIMITS.maxDigestBytes,
|
||||||
|
);
|
||||||
|
const treeDigest = optionalBoundedString(
|
||||||
|
record.tree_digest,
|
||||||
|
"Skill provenance tree digest",
|
||||||
|
SKILL_API_LIMITS.maxDigestBytes,
|
||||||
|
);
|
||||||
|
const revision = record.revision === undefined
|
||||||
|
? undefined
|
||||||
|
: safeInteger(record.revision, "Skill provenance revision");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!id.startsWith(`${kind}:`) || virtualPath === undefined ||
|
||||||
|
sourceDigest === undefined || !isVirtualPath(virtualPath)
|
||||||
|
) {
|
||||||
|
throw contractError("invalid Skill provenance");
|
||||||
|
}
|
||||||
|
if (kind === "builtin") {
|
||||||
|
if (revision !== undefined || treeDigest !== undefined) {
|
||||||
|
throw contractError("invalid built-in Skill provenance");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (revision === undefined || treeDigest === undefined) {
|
||||||
|
throw contractError("incomplete Workspace Skill provenance");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
revision !== projection.config_revision ||
|
||||||
|
treeDigest !== projection.tree_digest
|
||||||
|
) {
|
||||||
|
throw contractError("stale Workspace Skill projection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
virtual_path: virtualPath,
|
||||||
|
revision,
|
||||||
|
source_digest: sourceDigest,
|
||||||
|
tree_digest: treeDigest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiagnostics(value: unknown): SkillDiagnostic[] {
|
||||||
|
return boundedArray(
|
||||||
|
value,
|
||||||
|
"Skill diagnostics",
|
||||||
|
SKILL_API_LIMITS.maxDiagnostics,
|
||||||
|
).map((diagnostic) => {
|
||||||
|
const record = strictObject(
|
||||||
|
diagnostic,
|
||||||
|
[
|
||||||
|
"severity",
|
||||||
|
"code",
|
||||||
|
"message",
|
||||||
|
"source",
|
||||||
|
],
|
||||||
|
"Skill diagnostic",
|
||||||
|
["source"],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
severity: diagnosticSeverity(record.severity),
|
||||||
|
code: boundedString(
|
||||||
|
record.code,
|
||||||
|
"Skill diagnostic code",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
message: boundedString(
|
||||||
|
record.message,
|
||||||
|
"Skill diagnostic message",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
source: optionalBoundedString(
|
||||||
|
record.source,
|
||||||
|
"Skill diagnostic source",
|
||||||
|
SKILL_API_LIMITS.maxPathBytes,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResource(value: unknown): SkillResourceRef {
|
||||||
|
const record = strictObject(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"supported",
|
||||||
|
"diagnostic",
|
||||||
|
],
|
||||||
|
"Skill resource",
|
||||||
|
["diagnostic"],
|
||||||
|
);
|
||||||
|
if (typeof record.supported !== "boolean") {
|
||||||
|
throw contractError("Skill resource supported must be a boolean");
|
||||||
|
}
|
||||||
|
const name = boundedString(
|
||||||
|
record.name,
|
||||||
|
"Skill resource name",
|
||||||
|
SKILL_API_LIMITS.maxPathBytes,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (!isVirtualPath(name)) {
|
||||||
|
throw contractError("invalid Skill resource virtual path");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: boundedString(
|
||||||
|
record.kind,
|
||||||
|
"Skill resource kind",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
name,
|
||||||
|
supported: record.supported,
|
||||||
|
diagnostic: optionalBoundedString(
|
||||||
|
record.diagnostic,
|
||||||
|
"Skill resource diagnostic",
|
||||||
|
SKILL_API_LIMITS.maxLabelBytes,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKind(value: unknown): SkillSourceKind {
|
||||||
|
if (value === "builtin" || value === "workspace") return value;
|
||||||
|
throw contractError("unsupported Skill provenance kind");
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticSeverity(value: unknown): SkillDiagnosticSeverity {
|
||||||
|
if (value === "error" || value === "warning") return value;
|
||||||
|
throw contractError("unsupported Skill diagnostic severity");
|
||||||
|
}
|
||||||
|
|
||||||
|
function activationStatus(value: unknown): SkillActivationStatus {
|
||||||
|
if (value === "active" || value === "inactive") return value;
|
||||||
|
throw contractError("unsupported Skill activation status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectionStatus(value: unknown): SkillProjectionStatus {
|
||||||
|
if (value === "valid" || value === "invalid") return value;
|
||||||
|
throw contractError("unsupported Skill projection status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown, label: string): number {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 ||
|
||||||
|
value > SKILL_API_LIMITS.maxSafeInteger
|
||||||
|
) {
|
||||||
|
throw contractError(`${label} must be a non-negative safe integer`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedArray(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
limit: number,
|
||||||
|
): unknown[] {
|
||||||
|
if (!Array.isArray(value) || value.length > limit) {
|
||||||
|
throw contractError(`${label} must be a bounded array`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBoundedString(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
limit: number,
|
||||||
|
): string | undefined {
|
||||||
|
return value === undefined
|
||||||
|
? undefined
|
||||||
|
: boundedString(value, label, limit, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
limit: number,
|
||||||
|
allowEmpty: boolean,
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" || (!allowEmpty && value.length === 0) ||
|
||||||
|
new TextEncoder().encode(value).length > limit
|
||||||
|
) {
|
||||||
|
throw contractError(`${label} must be a bounded string`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVirtualPath(value: string): boolean {
|
||||||
|
return !value.startsWith("/") && !value.includes("\\") &&
|
||||||
|
value.split("/").every((part) =>
|
||||||
|
part !== "" && part !== "." && part !== ".."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictObject(
|
||||||
|
value: unknown,
|
||||||
|
allowedKeys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
optionalKeys: readonly string[] = [],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw contractError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const allowed = new Set(allowedKeys);
|
||||||
|
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
||||||
|
throw contractError(`${label} contains unknown fields`);
|
||||||
|
}
|
||||||
|
const optional = new Set(optionalKeys);
|
||||||
|
if (allowedKeys.some((key) => !optional.has(key) && !(key in record))) {
|
||||||
|
throw contractError(`${label} is missing required fields`);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractError(message: string): SkillApiContractError {
|
||||||
|
return new SkillApiContractError(message.slice(0, 256));
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import {
|
||||||
|
parseSkillCatalogResponse,
|
||||||
|
parseSkillDetailResponse,
|
||||||
|
SkillApiContractError,
|
||||||
|
} from "../src/lib/workspace/skills/api.ts";
|
||||||
|
import { SKILL_API_LIMITS } from "../src/lib/generated/skill-api.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContractError(value: () => unknown, expected: string): void {
|
||||||
|
try {
|
||||||
|
value();
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof SkillApiContractError,
|
||||||
|
"expected SkillApiContractError",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
error.message.includes(expected),
|
||||||
|
`expected bounded diagnostic containing ${expected}, got ${error.message}`,
|
||||||
|
);
|
||||||
|
assert(error.message.length <= 256, "diagnostic must remain bounded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("expected parser to reject malformed Skill response");
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtinProvenance() {
|
||||||
|
return {
|
||||||
|
kind: "builtin",
|
||||||
|
id: "builtin:errors",
|
||||||
|
virtual_path: "skills/errors/SKILL.md",
|
||||||
|
source_digest: "builtin-source-digest",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceProvenance() {
|
||||||
|
return {
|
||||||
|
kind: "workspace",
|
||||||
|
id: "workspace:release",
|
||||||
|
virtual_path: "skills/release/SKILL.md",
|
||||||
|
revision: 42,
|
||||||
|
source_digest: "workspace-source-digest",
|
||||||
|
tree_digest: "tree-digest",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogFixture(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
authority: "workspace-config-skills-v1",
|
||||||
|
projection: { config_revision: 42, tree_digest: "tree-digest" },
|
||||||
|
entries: [{
|
||||||
|
name: "errors",
|
||||||
|
description: "Builtin guidance",
|
||||||
|
activation_status: "active",
|
||||||
|
projection_status: "valid",
|
||||||
|
provenance: builtinProvenance(),
|
||||||
|
overrides: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}, {
|
||||||
|
name: "release",
|
||||||
|
description: "Workspace guidance",
|
||||||
|
activation_status: "inactive",
|
||||||
|
projection_status: "invalid",
|
||||||
|
provenance: workspaceProvenance(),
|
||||||
|
overrides: [builtinProvenance()],
|
||||||
|
diagnostics: [{
|
||||||
|
severity: "error",
|
||||||
|
code: "invalid_projection",
|
||||||
|
message: "invalid projected Skill",
|
||||||
|
source: "workspace:release",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailFixture(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
authority: "workspace-config-skills-v1",
|
||||||
|
projection: { config_revision: 42, tree_digest: "tree-digest" },
|
||||||
|
name: "release",
|
||||||
|
description: "Workspace guidance",
|
||||||
|
provenance: workspaceProvenance(),
|
||||||
|
overrides: [],
|
||||||
|
diagnostics: [],
|
||||||
|
activation_status: "active",
|
||||||
|
projection_status: "valid",
|
||||||
|
body: "# Release\n",
|
||||||
|
allowed_tools: ["Bash"],
|
||||||
|
allowed_tools_status: "experimental_hint_only",
|
||||||
|
resources: [{
|
||||||
|
kind: "reference",
|
||||||
|
name: "skills/release/references/checklist.md",
|
||||||
|
supported: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Skill catalog parser accepts generated builtin, Workspace, and invalid projection shapes", () => {
|
||||||
|
const parsed = parseSkillCatalogResponse(catalogFixture());
|
||||||
|
assertEquals(parsed.entries.length, 2);
|
||||||
|
assertEquals(parsed.entries[0].provenance.kind, "builtin");
|
||||||
|
assertEquals(parsed.entries[1].activation_status, "inactive");
|
||||||
|
assertEquals(parsed.entries[1].projection_status, "invalid");
|
||||||
|
assertEquals(parsed.projection.config_revision, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill detail parser preserves shared generated DTO fields", () => {
|
||||||
|
const parsed = parseSkillDetailResponse(detailFixture());
|
||||||
|
assertEquals(parsed.name, "release");
|
||||||
|
assertEquals(parsed.allowed_tools, ["Bash"]);
|
||||||
|
assertEquals(parsed.resources[0].supported, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill parser rejects stale Workspace projection revision and digest", () => {
|
||||||
|
const staleRevision = catalogFixture();
|
||||||
|
(staleRevision.projection as Record<string, unknown>).config_revision = 43;
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(staleRevision),
|
||||||
|
"stale Workspace Skill projection",
|
||||||
|
);
|
||||||
|
|
||||||
|
const staleDigest = catalogFixture();
|
||||||
|
(staleDigest.projection as Record<string, unknown>).tree_digest = "new-tree";
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(staleDigest),
|
||||||
|
"stale Workspace Skill projection",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill parser fails closed on unknown fields and newer enum values", () => {
|
||||||
|
const unknownField = catalogFixture();
|
||||||
|
unknownField.unexpected = true;
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(unknownField),
|
||||||
|
"unknown fields",
|
||||||
|
);
|
||||||
|
|
||||||
|
const newerProvenance = catalogFixture();
|
||||||
|
const entries = newerProvenance.entries as Record<string, unknown>[];
|
||||||
|
(entries[0].provenance as Record<string, unknown>).kind = "remote_catalog";
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(newerProvenance),
|
||||||
|
"unsupported Skill provenance kind",
|
||||||
|
);
|
||||||
|
|
||||||
|
const newerStatus = catalogFixture();
|
||||||
|
const newerEntries = newerStatus.entries as Record<string, unknown>[];
|
||||||
|
newerEntries[0].projection_status = "stale";
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(newerStatus),
|
||||||
|
"unsupported Skill projection status",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill parser rejects unsafe revisions and oversized collections or strings", () => {
|
||||||
|
const unsafeRevision = catalogFixture();
|
||||||
|
(unsafeRevision.projection as Record<string, unknown>).config_revision =
|
||||||
|
Number.MAX_SAFE_INTEGER + 1;
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(unsafeRevision),
|
||||||
|
"safe integer",
|
||||||
|
);
|
||||||
|
|
||||||
|
const oversizedCatalog = catalogFixture();
|
||||||
|
const firstEntry = (oversizedCatalog.entries as unknown[])[0];
|
||||||
|
oversizedCatalog.entries = Array.from(
|
||||||
|
{ length: SKILL_API_LIMITS.maxCatalogEntries + 1 },
|
||||||
|
() => firstEntry,
|
||||||
|
);
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillCatalogResponse(oversizedCatalog),
|
||||||
|
"bounded array",
|
||||||
|
);
|
||||||
|
|
||||||
|
const oversizedDetail = detailFixture();
|
||||||
|
oversizedDetail.body = "x".repeat(SKILL_API_LIMITS.maxBodyBytes + 1);
|
||||||
|
assertContractError(
|
||||||
|
() => parseSkillDetailResponse(oversizedDetail),
|
||||||
|
"bounded string",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Skill parser diagnostics never include rejected Skill body content", () => {
|
||||||
|
const secret = "SENSITIVE-SKILL-BODY-CONTENT";
|
||||||
|
const malformed = detailFixture();
|
||||||
|
malformed.body = secret;
|
||||||
|
malformed.provenance = {
|
||||||
|
...workspaceProvenance(),
|
||||||
|
kind: "newer_source_kind",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
parseSkillDetailResponse(malformed);
|
||||||
|
throw new Error("expected malformed provenance to fail");
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof SkillApiContractError,
|
||||||
|
"expected SkillApiContractError",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!error.message.includes(secret),
|
||||||
|
"diagnostic leaked Skill body content",
|
||||||
|
);
|
||||||
|
assert(error.message.length <= 256, "diagnostic must remain bounded");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user