Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
32e7e97
feat(code): add Hooks v2 capability snapshots
johannes117 Jul 21, 2026
f034bcd
fix(code): harden Hooks v2 command execution
johannes117 Jul 21, 2026
e95b385
feat(code): add Hooks v2 session transcripts
johannes117 Jul 21, 2026
3fd18f1
fix(code): gate project hooks on workspace trust
johannes117 Jul 21, 2026
8d44e5a
cr
johannes117 Jul 22, 2026
f8b2540
Merge branch 'main' into johannes117/code/dcd-70-hooks-capabilities
johannes117 Jul 22, 2026
0d0cd04
Merge branch 'johannes117/code/dcd-70-hooks-capabilities' into johann…
johannes117 Jul 22, 2026
0fa53a8
Merge branch 'johannes117/code/dcd-70-hooks-execution' into johannes1…
johannes117 Jul 22, 2026
6ede6a8
fix(code): pass workspace trust to hooks runtime
johannes117 Jul 22, 2026
ec93298
Merge branch 'main' into johannes117/code/dcd-70-hooks-execution
johannes117 Jul 22, 2026
1334152
Merge branch 'johannes117/code/dcd-70-hooks-execution' into johannes1…
johannes117 Jul 22, 2026
aa858c2
Merge branch 'main' into johannes117/code/dcd-70-hooks-execution
johannes117 Jul 22, 2026
255a486
Merge branch 'johannes117/code/dcd-70-hooks-execution' into johannes1…
johannes117 Jul 22, 2026
51c5b2b
cleanup
johannes117 Jul 22, 2026
d672f62
cr
johannes117 Jul 22, 2026
5f5df7f
Merge branch 'main' into johannes117/code/dcd-70-hooks-execution
johannes117 Jul 22, 2026
36bcd9a
Merge branch 'johannes117/code/dcd-70-hooks-execution' into johannes1…
johannes117 Jul 22, 2026
48dcd8c
Merge branch 'main' into johannes117/code/dcd-70-hooks-transcripts
johannes117 Jul 23, 2026
99be05d
fix(code): redact transcript URL path credentials
johannes117 Jul 23, 2026
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
163 changes: 163 additions & 0 deletions libs/code/deepagents_code/hooks/runtime.py
Comment thread
johannes117 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Session-scoped client facade for the Hooks v2 runtime."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import ( # noqa: TC003 - used in runtime fields and path joins
Path,
)
from typing import TYPE_CHECKING

from deepagents_code.hooks.engine import HookEngine
from deepagents_code.hooks.loading import load_hooks_config
from deepagents_code.hooks.models.domain import (
HookDecision,
HookInvocation,
SubagentStartEvent,
SubagentStopEvent,
)
from deepagents_code.hooks.snapshot import HooksSnapshot
from deepagents_code.hooks.transcript import TranscriptStore
from deepagents_code.model_config import DEFAULT_CONFIG_DIR

if TYPE_CHECKING:
from collections.abc import Sequence

from langchain_core.messages import BaseMessage


@dataclass(frozen=True, slots=True)
class PreparedHookInvocation:
"""Client-only materialization needed to build one hook wire envelope."""

invocation: HookInvocation
transcript_path: Path
transcript_revision: str
agent_transcript_path: Path | None = None
agent_transcript_revision: str | None = None


@dataclass(frozen=True, slots=True)
class HooksRuntime:
"""Client-owned session runtime around an immutable Hooks snapshot.

Owns configuration snapshot identity, transcript materialization, and the
`HookEngine`. Lifecycle call sites are intentionally not wired here.
"""

snapshot: HooksSnapshot
transcripts: TranscriptStore
engine: HookEngine
cwd: Path

@classmethod
def create(
cls,
*,
cwd: Path,
workspace_trusted: bool = False,
config_dir: Path | None = None,
transcript_root: Path | None = None,
) -> HooksRuntime:
"""Load configuration once and freeze a session runtime.

Args:
cwd: Session working directory.
workspace_trusted: Whether project-scoped hooks may be loaded.
config_dir: Alternate user config directory for tests.
transcript_root: Alternate transcript store root. Defaults to
`~/.deepagents/transcripts`, or `{config_dir}/transcripts` when
an alternate user configuration directory is provided.

Returns:
A runtime ready to execute invocations for this session.
"""
loaded = load_hooks_config(
project_root=cwd,
workspace_trusted=workspace_trusted,
config_dir=config_dir,
)
snapshot = HooksSnapshot.from_config(
loaded.config,
diagnostics=loaded.diagnostics,
snapshot_id=loaded.snapshot_id,
)
user_config_dir = config_dir or DEFAULT_CONFIG_DIR
store = TranscriptStore(transcript_root or user_config_dir / "transcripts")
engine = HookEngine(snapshot)
return cls(snapshot=snapshot, transcripts=store, engine=engine, cwd=cwd)

@property
def snapshot_id(self) -> str:
"""Canonical configuration hash for this session."""
return self.snapshot.snapshot_id

def append_messages(
self,
thread_id: str,
messages: Sequence[BaseMessage],
*,
agent_id: str | None = None,
) -> None:
"""Buffer conversation messages into the client transcript store.

Args:
thread_id: Conversation thread identifier.
messages: LangChain messages to project.
agent_id: Optional subagent scope.
"""
self.transcripts.append_messages(thread_id, messages, agent_id=agent_id)

async def invoke(self, invocation: HookInvocation) -> HookDecision:
"""Materialize transcripts, execute matching handlers, and return a decision.

Args:
invocation: Domain lifecycle invocation.

Returns:
Event-specific decision with notices, sequences, and diagnostics.
"""
prepared = self.prepare_invocation(invocation)
Comment thread
johannes117 marked this conversation as resolved.
return await self.engine.run(
prepared.invocation,
transcript_path=prepared.transcript_path,
agent_transcript_path=prepared.agent_transcript_path,
)

def prepare_invocation(
self,
invocation: HookInvocation,
) -> PreparedHookInvocation:
"""Materialize client-only transcript paths and revision identity.

Args:
invocation: Domain lifecycle invocation.

Returns:
A prepared value kept outside domain and graph state.
"""
context = invocation.context
thread_handle = self.transcripts.materialize(context.thread_id)
agent_id: str | None = None
if isinstance(invocation.event, SubagentStartEvent | SubagentStopEvent):
agent_id = invocation.event.agent.id
elif context.agent is not None:
agent_id = context.agent.id

agent_path: Path | None = None
agent_revision: str | None = None
if agent_id is not None:
agent_handle = self.transcripts.materialize(
context.thread_id,
agent_id=agent_id,
)
agent_path = agent_handle.path
agent_revision = agent_handle.revision

return PreparedHookInvocation(
invocation=invocation,
transcript_path=thread_handle.path,
transcript_revision=thread_handle.revision,
agent_transcript_path=agent_path,
agent_transcript_revision=agent_revision,
)
Loading