From 95ac0191457bb949baf0670113835ec5a92df74a Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 13 Oct 2026 19:32:35 +0100 Subject: [PATCH 1/2] chore(ahp): migrate SDK to 0.7.0 --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/ahp.rs | 97 ++++++++++++++++++++--------------------------- src/ahp/prompt.rs | 2 +- src/ahp/tests.rs | 55 +++++++++++++++++++++++++++ src/ahp/tool.rs | 17 ++++----- 6 files changed, 108 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e6cb7fe..88d531fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,9 +89,9 @@ dependencies = [ [[package]] name = "agenthooksprotocol" -version = "0.4.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "036c046bd6b6f4b4311f7ecdcb57972eb44e738cf65196ebc8501b4fdfb135c8" +checksum = "231a091332239a837199f42a7016d06dc99d0c9db34935943215e40dbf600fcd" dependencies = [ "jsonschema 0.33.0", "reqwest 0.12.28", diff --git a/Cargo.toml b/Cargo.toml index 22b30754..0730599d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ tokio-tungstenite = { version = "=0.30.0", default-features = false, features = a2a-protocol-client = "=0.11.0" a2a-protocol-server = { version = "=0.11.0", default-features = false } a2a-protocol-types = "=0.11.0" -agenthooksprotocol = { version = "=0.4.1", default-features = false, features = ["reqwest", "tokio-process"] } +agenthooksprotocol = { version = "=0.7.0", default-features = false, features = ["reqwest", "tokio-process"] } agent-client-protocol = { package = "agentkit-acp-sdk", version = "=2.2.0", features = ["unstable_session_fork", "unstable_session_inject"] } agent-client-protocol-schema = { package = "agentkit-acp-schema", version = "=1.9.1", features = ["unstable_session_notices", "unstable_session_compaction"] } agent-client-protocol-http = { package = "agentkit-acp-http", version = "=2.2.0", default-features = false, features = ["server", "client"] } diff --git a/src/ahp.rs b/src/ahp.rs index 76af1897..73c29055 100644 --- a/src/ahp.rs +++ b/src/ahp.rs @@ -6,9 +6,9 @@ mod tool; use std::path::Path; use std::sync::Arc; -use agenthooksprotocol::capability::{self, ModifyOperation}; +use agenthooksprotocol::capability::{self, HostCapabilities, Modify}; use agenthooksprotocol::client::{ - ConsumerProtocolOutcome, DeliveryDiagnostic, HookError, SettledCandidate, + DeliveryDiagnostic, HookError, ProtocolOutcome, SettledCandidate, }; use agenthooksprotocol::generated::{Effect, Event, Presence}; use agenthooksprotocol::hooks::{EventOutcome, HooksOptions}; @@ -19,8 +19,8 @@ use agenthooksprotocol::inputs::{ use agenthooksprotocol::{ Attachment, CanonicalMessageRole, ContentItem, ContextCompactAfterInputRemovedItem, ContextCompactBeforeInputTrigger, EventType, ExecutionEventExecution, - ExecutionEventExecutionExecuted, Registration, Session, SessionEndInputOutcome, - SessionStartInputHarness, SessionStartInputTrigger, TextBodyPart, TextPart, TextParts, + ExecutionEventExecutionExecuted, Harness, Registration, Session, SessionEndInputOutcome, + SessionStartInputTrigger, TextBodyPart, TextPart, TextParts, }; use agentkit_core::{FinishReason, Item, ItemKind, Part, ToolOutput, TurnCancellation}; use agentkit_loop::LoopError; @@ -68,7 +68,6 @@ impl Hooks { pub(crate) async fn session_start(&self, session: &str, trigger: SessionStartInputTrigger) { let input = SessionStartInput::new( - SessionStartInputHarness::new("kit", env!("CARGO_PKG_VERSION")), Vec::new(), "allow-all".into(), Box::new(Session::new(session)), @@ -110,7 +109,7 @@ impl Hooks { if let Some(SettledCandidate::Summary(summary)) = outcome.outcome.candidate() { return Some(Compaction::Summary(parts_text(summary))); } - match outcome.settled_event { + match outcome.event { Event::ContextCompactBefore(event) => match &event.0.instructions { Presence::Present(edited) if parts_text(edited) != instructions => { Some(Compaction::Instructions(parts_text(edited))) @@ -140,7 +139,7 @@ impl Hooks { EventType::ContextCompactAfter, self.sdk.context_compact_after(input).await, ) - .map(|outcome| outcome.settled_event) + .map(|outcome| outcome.event) { Some(Event::ContextCompactAfter(event)) => parts_text(&event.0.summary), _ => summary, @@ -162,49 +161,40 @@ pub(crate) enum Compaction { } fn options() -> Result { - use ModifyOperation::{Merge, Replace}; - let declarations = (|| { - Ok::<_, capability::Error>([ - (EventType::SessionStart, capability::observe()), - (EventType::SessionEnd, capability::observe()), - ( - EventType::UserMessageInbound, - capability::intercept() - .deny() - .modify_prompt([Replace])? - .build()?, - ), - ( - EventType::ModelRequestBefore, - capability::intercept().deny().build()?, - ), - (EventType::ModelResponseAfter, capability::observe()), - (EventType::ModelError, capability::observe()), - ( - EventType::ToolBefore, - capability::intercept() - .allow() - .deny() - .r#return() - .modify_input([Replace, Merge])? - .build()?, - ), - (EventType::ToolAfter, capability::observe()), - ( - EventType::ContextCompactBefore, - capability::intercept() - .r#return() - .modify_instructions([Replace])? - .build()?, - ), - ( - EventType::ContextCompactAfter, - capability::intercept().modify_summary([Replace])?.build()?, - ), - ]) - })() - .map_err(|error| error.to_string())?; - HooksOptions::from_declarations(SOURCE, declarations).map_err(|error| error.to_string()) + let capabilities = HostCapabilities { + session_start: Some(capability::session_start().observe()), + session_end: Some(capability::session_end().observe()), + user_message_inbound: Some( + capability::user_message_inbound() + .deny() + .modify_prompt(Modify::Replace), + ), + model_request_before: Some(capability::model_request_before().deny()), + model_response_after: Some(capability::model_response_after().observe()), + model_error: Some(capability::model_error().observe()), + tool_before: Some( + capability::tool_before() + .allow() + .deny() + .return_result() + .modify_input(Modify::ReplaceAndMerge), + ), + tool_after: Some(capability::tool_after().observe()), + context_compact_before: Some( + capability::context_compact_before() + .return_result() + .modify_instructions(Modify::Replace), + ), + context_compact_after: Some( + capability::context_compact_after().modify_summary(Modify::Replace), + ), + ..HostCapabilities::default() + }; + Ok(HooksOptions::new( + SOURCE, + Harness::new("kit", env!("CARGO_PKG_VERSION")), + capabilities, + )) } fn read(path: &Path) -> Result, String> { @@ -246,10 +236,7 @@ fn report(event: EventType, diagnostics: &[DeliveryDiagnostic]) { } /// Reports delivery diagnostics; a dispatch error is reported and yields `None`. -fn settle( - event: EventType, - result: Result, HookError>, -) -> Option> { +fn settle(event: EventType, result: Result) -> Option { match result { Ok(outcome) => { report(event, &outcome.diagnostics); @@ -262,7 +249,7 @@ fn settle( } } -fn denial(outcome: &ConsumerProtocolOutcome) -> String { +fn denial(outcome: &ProtocolOutcome) -> String { outcome .responses .iter() diff --git a/src/ahp/prompt.rs b/src/ahp/prompt.rs index 0fd7abb4..6e41c1e7 100644 --- a/src/ahp/prompt.rs +++ b/src/ahp/prompt.rs @@ -89,7 +89,7 @@ impl LoopMutator for PromptHook { }; let edited = match super::settle(EventType::UserMessageInbound, result) { Some(outcome) if outcome.outcome.is_denied() => Err(super::denial(&outcome.outcome)), - Some(outcome) => match outcome.settled_event { + Some(outcome) => match outcome.event { Event::UserMessageInbound(event) => Ok(event .message .messages diff --git a/src/ahp/tests.rs b/src/ahp/tests.rs index b0faea3c..403d8eac 100644 --- a/src/ahp/tests.rs +++ b/src/ahp/tests.rs @@ -24,6 +24,61 @@ fn every_granted_event_accepts_wildcard_subscriptions() { hooks(vec![intercept(&["*"]), observe(&["*"])]); } +#[tokio::test] +async fn every_lifecycle_event_reports_harness_identity() { + let (hooks, backend) = hooks(vec![intercept(&["*"]), observe(&["*"])]); + prompt(&hooks, &Model::default(), "hello").await; + call(&hooks, &Echo::new(), json!({"text": "hello"})).await; + hooks + .session_end("abc", SessionEndInputOutcome::Completed) + .await; + let items = [Item::text(ItemKind::User, "old")]; + hooks + .compact_before(&items, true, "instructions", None) + .await; + hooks.compact_after(&items, "summary".into()).await; + + let input = agenthooksprotocol::inputs::ModelErrorInput::new( + Box::new(agenthooksprotocol::ExecutionEventAttempt::new( + "attempt", + agenthooksprotocol::Integer::new(1.into()).unwrap(), + )), + Box::new(agenthooksprotocol::ExecutionEventError::new( + "provider", "failed", + )), + agenthooksprotocol::ModelErrorInputExecution::Executed( + agenthooksprotocol::ModelErrorInputExecutionExecuted::new(), + ), + Box::new(agenthooksprotocol::ExecutionEventModel::new("test", "test")), + ); + hooks.sdk.model_error(input).await.unwrap(); + + for event in [ + "session.start", + "session.end", + "user.message.inbound", + "model.request.before", + "model.response.after", + "model.error", + "tool.before", + "tool.after", + "context.compact.before", + "context.compact.after", + ] { + let delivered = backend.events(event); + assert!(!delivered.is_empty(), "missing {event}"); + for delivered in delivered { + assert_eq!( + delivered["harness"], + json!({ + "name": "kit", "version": env!("CARGO_PKG_VERSION"), + }), + "{event}" + ); + } + } +} + #[test] fn project_hooks_replace_global_hooks_with_the_same_id() { let registration = |hooks: Value| -> Registration { diff --git a/src/ahp/tool.rs b/src/ahp/tool.rs index d66cd6ec..4d559473 100644 --- a/src/ahp/tool.rs +++ b/src/ahp/tool.rs @@ -1,6 +1,6 @@ use std::sync::{Arc, Mutex}; -use agenthooksprotocol::client::{Decision, HookError}; +use agenthooksprotocol::client::{HookError, Permission}; use agenthooksprotocol::contracts::Contract; use agenthooksprotocol::generated::Presence; use agenthooksprotocol::inputs::{MessageInput, ToolAfterInput, ToolBeforeInput}; @@ -69,7 +69,7 @@ impl HookedTool { .arguments_contract(Contract::::validated(move |input| { validator.validate(input).map_err(|error| error.to_string()) })) - .initial_snapshot(state::initial(Decision::Allow)) + .initial_snapshot(state::initial(Permission::Allow)) { Ok(boundary) => boundary, Err(error) => return Before::Return(denied(error.to_string())), @@ -89,7 +89,7 @@ impl HookedTool { if outcome.outcome.is_denied() { return Before::Return(denied(super::denial(&outcome.outcome))); } - if let Some(value) = outcome.outcome.raw().supplied_result() { + if let Some(value) = outcome.outcome.candidate() { let output = match value { Value::String(text) => ToolOutput::text(text.clone()), value => ToolOutput::structured(value.clone()), @@ -102,14 +102,11 @@ impl HookedTool { } let reauthorize = !outcome.outcome.can_execute(); if reauthorize - && !(outcome.outcome.raw().approval_invalidated && outcome.outcome.requires_approval()) + && !(outcome.outcome.approval_invalidated && outcome.outcome.requires_approval()) { return Before::Return(denied("agent hook did not authorize the call".into())); } - request.input = match outcome.input { - Ok(input) => input, - Err(error) => return Before::Return(denied(error.to_string())), - }; + request.input = outcome.input; if reauthorize && let Some(outcome) = self.authorize(&request, context) { return Before::Return(outcome); } @@ -243,8 +240,8 @@ impl HookedTool { .with_items(items) .into_host_event() { - Ok((event, _)) => self.hooks.sdk.event_for("tool.after", event).await, - Err(error) => Err(HookError(error.to_string())), + Ok((event, _)) => self.hooks.sdk.event_for(EventType::ToolAfter, event).await, + Err(error) => Err(HookError::new(error.to_string())), }; super::settle(EventType::ToolAfter, result); } From e53c812236e3f8ec572da4a5e2ddcd0cb4ff82a1 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 13 Oct 2026 20:21:22 +0100 Subject: [PATCH 2/2] fix(ahp): preserve supplied-result permission guard --- src/ahp/tests.rs | 21 +++++++++++++++++++++ src/ahp/tool.rs | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ahp/tests.rs b/src/ahp/tests.rs index 403d8eac..f8cd3f2f 100644 --- a/src/ahp/tests.rs +++ b/src/ahp/tests.rs @@ -335,6 +335,27 @@ async fn tool_before_return_supplies_the_result() { assert!(echo.received.lock().unwrap().is_empty()); } +#[tokio::test] +async fn tool_before_edited_return_requires_explicit_hook_allow() { + for allowed in [false, true] { + let (hooks, backend) = hooks(vec![intercept(&["tool.before"])]); + let mut effects = vec![ + json!({"type": "modify", "target": "input", "operation": "merge", "value": {"text": "edited"}}), + json!({"type": "return", "value": "cached"}), + ]; + if allowed { + effects.push(json!({"type": "allow"})); + } + backend.respond("tool.before", json!(effects)); + let outcome = call(&hooks, &Echo::new(), json!({"text": "hello"})).await; + + assert_eq!( + output(outcome), + ToolOutput::text(if allowed { "cached" } else { "edited" }), + ); + } +} + #[tokio::test] async fn tool_after_observes_the_output() { let (hooks, backend) = hooks(vec![observe(&["tool.after"])]); diff --git a/src/ahp/tool.rs b/src/ahp/tool.rs index 4d559473..87bb0b4c 100644 --- a/src/ahp/tool.rs +++ b/src/ahp/tool.rs @@ -89,7 +89,7 @@ impl HookedTool { if outcome.outcome.is_denied() { return Before::Return(denied(super::denial(&outcome.outcome))); } - if let Some(value) = outcome.outcome.candidate() { + if let Some(value) = outcome.outcome.supplied_result() { let output = match value { Value::String(text) => ToolOutput::text(text.clone()), value => ToolOutput::structured(value.clone()),