Skip to content
Closed
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
193 changes: 147 additions & 46 deletions Cargo.lock

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tui = [
]

[dependencies]
agenthooksprotocol = { version = "=0.4.1", default-features = false, features = ["reqwest", "tokio-process"] }
syntect = { version = "=5.3.0", default-features = false, features = ["parsing", "regex-fancy"], optional = true }
two-face = { version = "=0.5.2", default-features = false, features = ["syntect-fancy"], optional = true }
opus-pure = { version = "=0.2.2", default-features = false, optional = true }
Expand All @@ -39,21 +40,21 @@ a2a-protocol-types = "=0.11.0"
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"] }
agentkit-acp = { version = "=0.10.12", features = ["unstable-inject"] }
agentkit-adapter-completions = "=0.10.9"
agentkit-core = "=0.10.5"
agentkit-context = "=0.10.5"
agentkit-compaction = "=0.10.5"
agentkit-loop = { version = "=0.10.13", features = ["otel"] }
agentkit-mcp = "=0.10.7"
agentkit-http = "=0.10.7"
agentkit-plugins = "=0.10.7"
agentkit-provider-openai = "=0.10.12"
agentkit-provider-openrouter = "=0.10.8"
agentkit-task-manager = "=0.10.7"
agentkit-tool-compose = { version = "=0.10.12", default-features = false, features = ["runlet"] }
agentkit-tool-skills = "=0.10.8"
agentkit-tools-core = "=0.10.5"
agentkit-acp = { version = "=0.10.12", features = ["unstable-inject"], git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-adapter-completions = { version = "=0.10.9", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-core = { version = "=0.10.5", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-context = { version = "=0.10.5", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-compaction = { version = "=0.10.5", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-loop = { version = "=0.10.13", features = ["otel"], git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-mcp = { version = "=0.10.7", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-http = { version = "=0.10.7", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-plugins = { version = "=0.10.7", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-provider-openai = { version = "=0.10.12", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-provider-openrouter = { version = "=0.10.8", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-task-manager = { version = "=0.10.7", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-tool-compose = { version = "=0.10.12", default-features = false, features = ["runlet"], git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-tool-skills = { version = "=0.10.8", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
agentkit-tools-core = { version = "=0.10.5", git = "https://github.com/danielkov/agentkit", rev = "3d75d7179ad6ef4d675d6b9d88cc1864f59be759" }
arboard = { version = "=3.6.1", default-features = false, features = ["image-data"], optional = true }
async-trait = "=0.1.92"
atomicwrites = "=0.4.4"
Expand Down
253 changes: 253 additions & 0 deletions docs/plans/agent-hooks-protocol.md

Large diffs are not rendered by default.

333 changes: 333 additions & 0 deletions docs/user/agent-hooks-protocol.md

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

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/user/getting-started-and-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ subdir = "agent-plugins/example"

`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"

project-local definitions replace conflicting global definitions. No TOML hook
configuration is required.
Comment on lines +292 to +294

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?

See [Native Agent Hooks Protocol](agent-hooks-protocol.md) for configuration,
security boundaries, and session lifetime behavior.

### Configuration precedence and built-in defaults

For settings exposed by a command, precedence is:
Expand Down
179 changes: 179 additions & 0 deletions scripts/ahp-herdr-smoke.py

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 does not belong in this project

Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Opt-in Kit native AHP/compose live smoke in a NEW named HerdR pane only.

Build first: mise run test -- --test ahp_herdr --no-run
Uses the real local work/.agents/hooks.json discovery path and isolates HOME for
only the test process. The named HerdR server retains its real HOME.
No compilation or manifest changes are performed by this script.
"""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile
import time
import uuid


def main():
repo = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--test-executable", type=Path)
parser.add_argument("--bridge", type=Path, default=repo.parent / "herdr-ahp/target/release/herdr-ahp")
args = parser.parse_args()
if os.environ.get("HERDR_ENV") != "1":
raise RuntimeError("Requires inherited HerdR detection; no focused-session fallback")
herdr = shutil.which("herdr")
if not herdr:
raise RuntimeError("herdr is not on PATH")
executable = args.test_executable
if executable is None:
candidates = [p for p in (repo / "target/debug/deps").glob("ahp_herdr-*") if p.is_file() and os.access(p, os.X_OK) and p.suffix != ".d"]
if not candidates:
raise RuntimeError("Compile tests/ahp_herdr.rs with mise run test -- --test ahp_herdr --no-run first")
executable = max(candidates, key=lambda p: p.stat().st_mtime)
executable = executable.resolve()
bridge = args.bridge.resolve()
if not executable.is_file() or not bridge.is_file():
raise RuntimeError("Test executable and built generic bridge must exist")
case = "native_ahp_herdr_real_compose"
listed = subprocess.run([str(executable), "--list"], capture_output=True, text=True, timeout=10)
if listed.returncode or case + ": test" not in listed.stdout:
raise RuntimeError("Executable does not expose the expected native Kit smoke case")
name = "kit-ahp-e2e-" + uuid.uuid4().hex[:10]
root = Path(tempfile.mkdtemp(prefix="kit-ahp-herdr-live-"))
gates = root / "gates"
gates.mkdir(mode=0o700)
isolated_home = root / "isolated-home"
isolated_home.mkdir(mode=0o700)
work = gates / "work"
(work / ".agents").mkdir(parents=True, mode=0o700)
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_")}
prefix = [herdr, "--session", name]

def cli(*words):
result = subprocess.run(prefix + list(words), env=env, capture_output=True, text=True, timeout=15)
if result.returncode:
raise RuntimeError(result.stderr[:1500])
return json.loads(result.stdout) if result.stdout.strip() else None

def wait(check, message):
deadline = time.monotonic() + 40
while time.monotonic() < deadline:
value = check()
if value:
return value
if (root / "test-exit").exists():
stderr = (root / "test.stderr").read_text()[-1500:]
stdout = (root / "test.stdout").read_text()[-1500:]
raise RuntimeError(message + ": test exited\n" + stdout + stderr)
time.sleep(0.05)
raise RuntimeError(message)

status = cli("status", "--json")["server"]
socket = status["socket"]
if status["running"] or socket == os.environ.get("HERDR_SOCKET_PATH") or ("/sessions/" + name + "/") not in socket:
raise RuntimeError("Refusing non-isolated or running session")
if Path(socket).parent.exists():
raise RuntimeError("Refusing existing stopped-session directory")
evidence = {"session": name, "socket_is_isolated": True, "test_executable": str(executable), "bridge": str(bridge), "checks": []}
started = False
try:
with (root / "server.log").open("w") as log:
server = subprocess.Popen(prefix + ["server"], env=env, stdout=log, stderr=log, start_new_session=True)
started = True
wait(lambda: cli("status", "--json")["server"]["running"], "Named headless server did not start")
evidence["version"] = cli("status", "--json")["server"]["version"]
created = cli("workspace", "create", "--cwd", str(repo), "--label", "kit-native-ahp-isolated-smoke", "--no-focus")
pane_id = created["result"]["root_pane"]["pane_id"]
evidence["pane"] = pane_id
(gates / "scope.json").write_text(json.dumps({"session": name, "socket": socket, "pane": pane_id, "home": str(isolated_home)}))

def pane():
return cli("pane", "get", pane_id)["result"]["pane"]

context_path = root / "bridge-context.json"
context_path.write_text(json.dumps({"source": "org.example.kit.native-herdr-smoke", "agent": "kit-smoke",
"sequence_file": str(root / "sequence"), "resume_argv": ["sh", "-c", "printf isolated-kit-smoke-{session_id}"], "release_on_eof": False}))
config_path = work / ".agents/hooks.json"
names = ["session.start", "turn.start", "turn.end"]
config_path.write_text(json.dumps({"protocolVersion": "draft", "hooks": [{
"id": "org.example.kit.native-herdr-smoke", "transport": {"type": "stdio", "command": str(bridge),
"args": ["--config", str(context_path)], "lifecycle": "persistent"},
"subscriptions": [{"events": names, "mode": "observe", "content": {"default": "metadata"}}]}]}))
evidence["canonical_hooks_file"] = str(config_path)
evidence["test_home"] = str(isolated_home)
q = shlex.quote
command = (f"env HOME={q(str(isolated_home))} KIT_AHP_HERDR_SMOKE=1 KIT_AHP_HERDR_GATE_DIR={q(str(gates))} "
f"{q(str(executable))} --ignored --exact {case} --nocapture --test-threads=1 "
f"> {q(str(root / 'test.stdout'))} 2> {q(str(root / 'test.stderr'))}; test_rc=$?; "
f"{q(str(bridge))} --config {q(str(context_path))} --release; release_rc=$?; "
f"printf '%s %s\\n' \"$test_rc\" \"$release_rc\" > {q(str(root / 'test-exit'))}; sleep 45")
cli("pane", "run", pane_id, "sh -c " + q(command))

def assert_state(stage, expected):
wait(lambda: (gates / stage).exists(), "Missing genuine model/driver boundary " + stage)
def accepted():
current = pane()
return current if current.get("agent") == "kit-smoke" and current.get("agent_status") in ({"idle", "done"} if expected == "idle" else {expected}) else None
current = wait(accepted, "Native event did not report " + expected)
evidence["checks"].append({"boundary": stage, "agent": current["agent"], "state": current["agent_status"]})

assert_state("session-initialized", "idle")
(gates / "allow-turn").touch()
assert_state("turn-working", "working")
(gates / "allow-compose").touch()
wait(lambda: (gates / "child-verified").exists(), "Real compose/edit result missing")
evidence["child"] = json.loads((gates / "child-verified").read_text())
effect = gates / "work/actual-compose-edit.txt"
assert effect.read_text() == "real compose child\n", "Actual native edit effect missing"
assert evidence["child"]["transcript_result_verified"] is True
assert_state("turn-finished", "idle")
evidence["final"] = json.loads((gates / "turn-finished").read_text())
assert evidence["final"]["final_output"] == "Kit native AHP HerdR smoke complete"
# Do not let SDK shutdown kill a pending last-state report: idle was
# independently observed before close/shutdown is admitted.
(gates / "allow-shutdown").touch()
wait(lambda: (root / "test-exit").exists(), "Native test close/exit missing")
assert (root / "test-exit").read_text().strip() == "0 0", "Test or explicit release failed"
assert (gates / "closed").exists(), "Native driver/SDK did not close"
released = pane()
assert not released.get("agent"), "Explicit exit release did not clear source ownership"
info = cli("pane", "process-info", "--pane", pane_id)["result"]["process_info"]
assert any(process["name"] == "sleep" for process in info.get("foreground_processes", [])), "Idle-shell fallback could mask release"
evidence["release_foreground_processes"] = [process["name"] for process in info["foreground_processes"]]
evidence["released_state"] = released["agent_status"]
evidence["passed"] = True
except Exception as error:
evidence["failure"] = str(error)
raise
finally:
cleanup_failed = False
if started:
for operation in ["stop", "delete"]:
try:
result = subprocess.run(prefix + ["session", operation, name, "--json"], env=env, capture_output=True, text=True, timeout=15)
evidence["cleanup_" + operation] = {"exit": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip()}
cleanup_failed = cleanup_failed or result.returncode != 0
except Exception as error:
evidence["cleanup_" + operation] = {"exit": None, "error": str(error)}
cleanup_failed = True
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
evidence["server_reap"] = "Owned named server did not exit; no process was killed directly"
cleanup_failed = True
if cleanup_failed:
evidence["passed"] = False
(root / "evidence.json").write_text(json.dumps(evidence, indent=2))
print(json.dumps({"session": name, "evidence": str(root / "evidence.json"), "passed": evidence.get("passed", False),
"checks": evidence["checks"], "cleanup": {k: v["exit"] for k, v in evidence.items() if k.startswith("cleanup_")}}, indent=2))
if cleanup_failed:
raise RuntimeError("Named test-resource cleanup incomplete; inspect evidence")


if __name__ == "__main__":
main()
Loading
Loading