Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/user/agent-hooks-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ global hook with the same `id`; other hooks from both files stay active. Kit
skips an unreadable or malformed file with a warning. A subscription
selector that matches no event Kit emits disables hooks with a warning.

Stdio hook processes inherit Kit's full environment, including any credentials
or other sensitive values present in the environment.
A transport's `env` entries override inherited values. Only configure stdio
hooks that you trust with those values.

Hook backends run local processes or receive session content. Review both files
before you run Kit in a project you do not trust.

Expand All @@ -46,6 +51,8 @@ before you run Kit in a project you do not trust.
| Event | Where Kit emits it | Hook authority |
| --- | --- | --- |
| `session.start` | A session's model connection opens | Observe |
| `turn.start` | A logical agent-loop turn starts, with native session and turn IDs | Observe |
| `turn.end` | That logical turn completes, fails, or is cancelled | Observe |
| `session.end` | A prompt run finishes or an ACP session closes | Observe |
| `user.message.inbound` | A new turn admits a user prompt | Deny, replace the prompt |
| `model.request.before` | Before each model call, with the transcript | Deny |
Expand All @@ -70,5 +77,13 @@ Tool events cover the tools that compose scripts call, such as `shell`, `edit`,
tool calls and tool results into message parts. It omits media, files and
reasoning.

Kit does not emit `turn.*`, `model.switch.*`, `config.change.*`, `file.changed`
Tool calls, model retries and model continuations stay within the same logical
turn; they do not emit extra turn boundaries. Kit delivers turn observations
in order, before the session's next hook event and whenever the session goes
idle. An abrupt process exit can drop a terminal observation. The start event has no transcript snapshot;
its trigger is reported as external because the loop does not expose a cause.
The continuation count is zero because Kit does not initiate AHP hook
continuations.

Kit does not emit `turn.finish.before`, `model.switch.*`, `config.change.*`, `file.changed`
or the tool permission and progress events yet.
4 changes: 2 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ run = [

[tasks.test]
description = "Rust tests; arguments pass through, e.g. mise run test -- --test cli or mise run test -- --test runtime some_case"
run = "cargo test --locked"
run = "scripts/isolated-home.sh cargo test --locked"

[tasks."test:headless"]
description = "Rust tests without the tui feature; arguments pass through like test"
run = "cargo test --locked --no-default-features"
run = "scripts/isolated-home.sh cargo test --locked --no-default-features"

[tasks."test:scripts"]
description = "Shell tests for the release scripts"
Expand Down
11 changes: 11 additions & 0 deletions scripts/isolated-home.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/sh
# Runs a command with an empty HOME so tests never load the developer's
# global ~/.agents hooks, skills or other user configuration.
set -eu

home=$(mktemp -d)
trap 'rm -rf "$home"' EXIT
CARGO_HOME=${CARGO_HOME:-$HOME/.cargo} \
RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup} \
HOME=$home \
"$@"
20 changes: 16 additions & 4 deletions src/ahp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mod model;
mod prompt;
mod tool;
mod turn;

use std::path::Path;
use std::sync::Arc;
Expand All @@ -28,13 +29,15 @@ use agentkit_loop::LoopError;
pub(crate) use model::{HookedAdapter, HookedSession};
pub(crate) use prompt::{PromptHook, PromptRecord};
pub(crate) use tool::hooked_tools;
pub(crate) use turn::TurnHook;

const SOURCE: &str = "urn:dev:kit:harness";
const CONFIG_FILE: &str = "hooks.json";

/// Registered hook backends with the event grants Kit implements.
pub struct Hooks {
sdk: agenthooksprotocol::Hooks,
sdk: Arc<agenthooksprotocol::Hooks>,
turn_events: turn::TurnQueues,
}

impl Hooks {
Expand Down Expand Up @@ -63,7 +66,10 @@ impl Hooks {
fn new(registration: Registration, options: HooksOptions) -> Result<Self, String> {
let sdk = agenthooksprotocol::Hooks::new(registration, options)
.map_err(|error| error.to_string())?;
Ok(Self { sdk })
Ok(Self {
sdk: Arc::new(sdk),
turn_events: turn::TurnQueues::default(),
})
}

pub(crate) async fn session_start(&self, session: &str, trigger: SessionStartInputTrigger) {
Expand All @@ -77,6 +83,8 @@ impl Hooks {
}

pub(crate) async fn session_end(&self, session: &str, outcome: SessionEndInputOutcome) {
self.drain_turn_events(session).await;
self.forget_turn_events(session);
let reason = outcome.as_str().to_owned();
let input = SessionEndInput::new(outcome, reason, Box::new(Session::new(session)));
settle(EventType::SessionEnd, self.sdk.session_end(input).await);
Expand Down Expand Up @@ -164,6 +172,8 @@ fn options() -> Result<HooksOptions, String> {
let capabilities = HostCapabilities {
session_start: Some(capability::session_start().observe()),
session_end: Some(capability::session_end().observe()),
turn_start: Some(capability::turn_start().observe()),
turn_end: Some(capability::turn_end().observe()),
user_message_inbound: Some(
capability::user_message_inbound()
.deny()
Expand All @@ -190,11 +200,13 @@ fn options() -> Result<HooksOptions, String> {
),
..HostCapabilities::default()
};
Ok(HooksOptions::new(
let mut options = HooksOptions::new(
SOURCE,
Harness::new("kit", env!("CARGO_PKG_VERSION")),
capabilities,
))
);
options.backend.inherit_environment = true;
Ok(options)
}

fn read(path: &Path) -> Result<Option<Registration>, String> {
Expand Down
3 changes: 3 additions & 0 deletions src/ahp/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ impl<S: ModelSession> ModelSession for HookedSession<S> {
let inner = self.inner.begin_turn(request, cancellation).await?;
return Ok(HookedTurn { inner, call: None });
};
hooks
.drain_turn_events(&request.session_id.to_string())
.await;
self.attempts += 1;
let Some(number) = Integer::new(self.attempts.into()) else {
let inner = self.inner.begin_turn(request, cancellation).await?;
Expand Down
3 changes: 3 additions & 0 deletions src/ahp/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ impl LoopMutator for PromptHook {
if ctx.point != MutationPoint::AfterTurnEnded {
return Ok(());
}
self.hooks
.drain_turn_events(&ctx.session_id.to_string())
.await;
let Some(prompt) = cursor.last().filter(|item| item.kind == ItemKind::User) else {
return Ok(());
};
Expand Down
18 changes: 13 additions & 5 deletions src/ahp/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ impl Backend {
self.effects.lock().unwrap().insert(event.into(), effects);
}

pub(crate) fn all_events(&self) -> Vec<Value> {
self.events.lock().unwrap().clone()
}

pub(crate) fn events(&self, event: &str) -> Vec<Value> {
self.events
.lock()
Expand Down Expand Up @@ -74,6 +78,13 @@ pub(crate) fn observe(events: &[&str]) -> Value {

pub(crate) fn hooks(subscriptions: Vec<Value>) -> (Arc<Hooks>, Arc<Backend>) {
let backend = Arc::new(Backend::default());
(hooks_with_backend(subscriptions, backend.clone()), backend)
}

pub(crate) fn hooks_with_backend(
subscriptions: Vec<Value>,
backend: Arc<dyn ManagedBackend>,
) -> Arc<Hooks> {
let registration = serde_json::from_value(json!({
"protocolVersion": "draft",
"hooks": [{
Expand All @@ -83,11 +94,8 @@ pub(crate) fn hooks(subscriptions: Vec<Value>) -> (Arc<Hooks>, Arc<Backend>) {
}],
}))
.unwrap();
let options = options().unwrap().with_backend(BACKEND, backend.clone());
(
Arc::new(Hooks::new(registration, options).unwrap()),
backend,
)
let options = options().unwrap().with_backend(BACKEND, backend);
Arc::new(Hooks::new(registration, options).unwrap())
}

pub(crate) fn text_message(text: &str) -> Value {
Expand Down
Loading
Loading