Skip to content

feat(ahp): integrate native Agent Hooks Protocol lifecycle - #266

Closed
danielkov wants to merge 2 commits into
mainfrom
feat/agent-hooks-protocol
Closed

danielkov wants to merge 2 commits into
mainfrom
feat/agent-hooks-protocol

Conversation

@danielkov

@danielkov danielkov commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Integrate Agent Hooks Protocol into Kit’s native session, turn, model, tool, compaction, ACP configuration, and committed-edit boundaries. Use SDK 0.4.1 typed settlement for effective native write-back, with invocation-scoped compose/MCP lineage and global/project hook discovery.

Impact

Hooks can observe implemented boundaries, modify supported prompt/tool/output/compaction values, and deny supported operations. Tool input edits are checked through SDK contracts using native argument decoders where available and advertised schemas for runtime-only tools. Invalid configuration sources are warned and skipped independently; registered hook failure policies remain enforced.

Technical details

Native authority and persistence

Await native lifecycle boundaries rather than bridging observer notifications into tasks. Validate native transcript invariants before applying settled edits and persist committed transcript rewrites across resume and fork.

SDK-owned settlement and content

Use typed SDK settled events, transports, reducers, cancellation, and attachment ownership. Ordinary messages remain inline; binary attachments are immutable. Preserve opaque facts and decode failures without granting execution authority.

Tool argument contracts

Keep the tool-contract implementation in a separate commit. Fourteen builtins reuse their invocation decoders through lossless wire envelopes; schema-only tools and lazily discovered MCP tools use runtime contracts without claiming compile-time argument types.

Lifecycle-capable AgentKit

Pin AgentKit to revision 3d75d7179ad6ef4d675d6b9d88cc1864f59be759 for awaited interception, terminal delivery, and transcript replacement notifications. Only implemented native effects are advertised.

@kit-code-agent kit-code-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compaction hook events use the summarizer session ID rather than the owning session ID. MCP child cancellation or timeout can drop terminal hook receipt delivery.

Comment thread src/compaction.rs
self.inner = self.inner.with_backend(HookedCompactionBackend {
backend: backend.clone(),
hooks,
session_id: session_id.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Report compaction hooks with the owning session ID

With AHP hooks enabled, runtime compaction passes a private compaction-<UUID> (or compaction-{session}) ID into automatic(). This tuple retains that summarizer/model session ID, and HookedCompactionBackend then emits it as session.id in both context.compact.before and context.compact.after. Neither event identifies the session whose transcript is being compacted, so consumers keyed by the owning session.start ID cannot correlate the operation and can miss session-specific instruction or summary policies. Pass the owning native session ID separately when attaching hooks and use it for both compaction events; retain the private ID only for the summarizer model session.

For agents:
Validate the following issue, address if needed:

<comment>Compaction hook events use the private summarizer session ID instead of the owning native session ID, breaking session correlation and session-specific policies.</comment>
<file_context>
@@ -668,11 +870,24 @@ fn user_message_from_marker(
 
 pub struct AutomaticCompactor {
     inner: StrategyCompactor,
+    hook_backend: Option<(Arc<dyn CompactionBackend>, SessionId)>,
     persistence: Option<SessionObserver>,
     mirror: Option<crate::session::TransientTranscript>,
 }
 
 impl AutomaticCompactor {
+    /// Applies AHP edits at generation and pre-installation boundaries.
+    pub(crate) fn with_hooks(mut self, hooks: Arc<crate::ahp::AhpHost>) -> Self {
+        if let Some((backend, session_id)) = &self.hook_backend {
+            self.inner = self.inner.with_backend(HookedCompactionBackend {
+                backend: backend.clone(),
+                hooks,
+                session_id: session_id.clone(),
+            });
+        }
+        self
+    }
+
     /// Applies each replacement to an in-memory transcript as well.
     pub(crate) fn mirroring(mut self, transcript: crate::session::TransientTranscript) -> Self {
         self.mirror = Some(transcript);
</file_context>

Comment thread src/tools/mcp.rs
"tool requires an execution scope".into(),
));
};
scope.executor = match &self.hooks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the MCP child terminal receipt on cancellation or timeout

Once a dynamically discovered MCP child completes tool.before, interrupting the turn or reaching timeout_seconds while remote execution is pending drops dispatch_call and its wrapped child future. AhpToolExecutor::run only calls hooks.after after awaiting the inner execution, so this path never emits the child's terminal tool.after; the timeout can also drop an in-progress after-hook delivery. The enclosing native tool wrapper's receipt has a different event identity and cannot close that child lineage, leaving hook consumers with an admitted invocation that never terminates. Retain the admitted child's effective request and event identity across cancellation/timeout, then await its bounded cancellation/error receipt outside the dropped dispatch future while preserving invocation/replay cleanup.

For agents:
Validate the following issue, address if needed:

<comment>Cancellation or timeout drops the hooked MCP child before its terminal receipt completes, leaving its admitted tool event lineage unclosed.</comment>
<file_context>
@@ -3446,11 +3454,19 @@ impl McpTool {
             Err(error) => return ToolExecutionOutcome::Failed(error),
         };
         let tool_name = ToolName::new(name.clone());
-        let Some(scope) = context.execution_scope.clone() else {
+        let Some(mut scope) = context.execution_scope.clone() else {
             return ToolExecutionOutcome::Failed(ToolError::Unavailable(
                 "tool requires an execution scope".into(),
             ));
         };
+        scope.executor = match &self.hooks {
+            Some(hooks) => Arc::new(super::ahp::AhpToolExecutor::for_context(
+                self.executor.clone(),
+                hooks.clone(),
+                context,
+            )),
+            None => self.executor.clone(),
+        };
         let timeout_name = name.clone();
         let call = tokio::time::timeout(timeout, async {
             // A resumed conversation can invoke a previously discovered tool
</file_context>

@kit-code-agent kit-code-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two previously reported P2 findings remain unresolved:

  • Compaction hooks still emit the private summarizer session ID rather than the owning session ID, breaking session correlation and session-specific policies.
  • Cancelling or timing out an admitted MCP child still drops its terminal tool.after receipt, leaving that child event lineage unclosed.

@danielkov danielkov left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll stop here, this is clearly half-baked in its current state. Of the almost 10k LoC, probably around 1k is warranted.

Comment on lines +4 to +5
(global) and `<root>/.agents/hooks.json` (project-local). No `[ahp]` TOML table is
required. If neither file exists, hooks are disabled. Registration validation,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(global) and `<root>/.agents/hooks.json` (project-local). No `[ahp]` TOML table is
required. If neither file exists, hooks are disabled. Registration validation,
(global) and `<root>/.agents/hooks.json` (project-local). If neither file exists, hooks are disabled. Registration validation,

implementation posterity


## Configure hooks

Put a canonical registration directly in either `hooks.json` file:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Put a canonical registration directly in either `hooks.json` file:
Put a registration directly in either `hooks.json` file:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file needs to be cleaned up, it's a blend of story telling, actual user docs, implementation details, tests (???); preserve user docs for user-facing docs

Comment on lines +292 to +294
`<root>/.agents/hooks.json`. Both canonical registrations are merged by hook ID;
project-local definitions replace conflicting global definitions. No TOML hook
configuration is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would TOML hook configuration be required?

`a2a` applies to `serve` and `tui`. Configured plugins can provide MCP servers without `mcp_config`; supported `stdio` and `streamable-http` declarations are registered, while `sse` declarations are skipped with a stderr diagnostic. MCP servers merge by name in this order: plugins, configured `mcp_config`, `<canonical-root>/.mcp.json`, then `--mcp-config`. Higher layers replace whole conflicts while preserving non-conflicting lower entries; live removal reveals the next lower layer. Plugin data is stored under `<config-directory>/plugin-data/<plugin-manifest-name>`. See [Agent Plugins](agent-plugins.md) for placeholders, collision rules, and ACP child behavior. `otel_endpoint` enables OTLP export of AgentKit's GenAI trace spans. `otel_protocol` accepts exactly `grpc`, `http/protobuf`, or `http/json`; gRPC is the default. For either HTTP protocol, Kit appends `/v1/traces` to a base endpoint unless it already ends with that path. `credential_store` selects one backend for OpenAI, Speakeasy, and MCP and defaults to `memory`; selecting `file` requires `credential_dir`, while a credential directory is invalid with `memory` or `keychain`. Memory credentials are process-local and are not shared with the TUI server process or nested Kit children. Standalone OpenAI and Speakeasy login requires persistent `keychain` or `file` storage. ACP profiles are direct executable-and-argument configurations, not shell command strings. `[subagent].harness` must name an available fully qualified profile such as `acp.review`; otherwise startup reports `unknown subagent ACP harness`. When no subagent harness is selected, the built-in `acp.kit` profile is used.

Native AHP hooks are discovered from `~/.agents/hooks.json` and
`<root>/.agents/hooks.json`. Both canonical registrations are merged by hook ID;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we stop using canonical, like it's "umm"

Comment thread src/ahp/config.rs
Comment on lines +61 to +83
let validate = || -> Result<Value, String> {
let value: Value = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
let capabilities = grants
.iter()
.map(|(name, declaration)| {
EventGrant::from_declaration(declaration).map(|grant| (name.clone(), grant))
})
.collect::<Result<_, _>>()
.map_err(|error| error.to_string())?;
// The SDK validates the canonical schema, duplicate backend ids and
// registration routes. Its registered transports are lazy: construction
// does not spawn a process, send a request or start a worker/runtime.
Hooks::new(
&value,
HooksOptions::new("urn:kit:ahp:config", capabilities),
)
.map_err(|error| error.to_string())?;
Ok(value)
};
validate()
.map(Some)
.map_err(|error| format!("{}: {error}", path.display()))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this entire function is extremely oddly structured

Comment thread src/ahp/config.rs
let global_path = global_path
.as_ref()
.ok_or_else(|| "AHP global registration has no source path".to_owned())?;
if global["protocolVersion"] != local["protocolVersion"] {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not our problem, SDK owns compatibility

Comment thread src/ahp/config.rs
Comment on lines +147 to +182
let hooks = global["hooks"].as_array_mut().ok_or_else(|| {
format!(
"{}: SDK-validated registration has no hooks array",
global_path.display()
)
})?;
let local_hooks = local["hooks"].as_array().ok_or_else(|| {
format!(
"{}: SDK-validated registration has no hooks array",
local_path.display()
)
})?;
for hook in local_hooks {
if let Some(index) = hooks
.iter()
.position(|existing| existing["id"] == hook["id"])
{
hooks[index] = hook.clone();
} else {
hooks.push(hook.clone());
}
}
let local_fields = local.as_object().ok_or_else(|| {
format!(
"{}: SDK-validated registration is not an object",
local_path.display()
)
})?;
for (key, value) in local_fields {
if key != "hooks" {
global[key] = value.clone();
}
}
global
}
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deserialize into the config struct, why are we trying to validate line by line?

Comment thread src/ahp/domain.rs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it'd probably be cleaner to define capabilities as a JSON that's then statically built into the binary

Comment thread src/ahp/projection.rs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this python or rust? none of this should be required

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant