Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5a11539
Answer self-liveness from the current process incarnation
trissim Oct 29, 2026
2ee0b07
Use store revisions and owned reader lifetimes for worker observations
trissim Oct 29, 2026
ba6e249
Keep physical reader lifetime separate from class-only SQLite policies
trissim Oct 29, 2026
627db9f
Keep goal projections fresh and route shared notifications by owner
trissim Oct 29, 2026
0824d16
Restore absent document capture through MissingFile
trissim Oct 29, 2026
b3223ac
Migrate room reader contract and nominal goal refresh boundaries
trissim Oct 29, 2026
fe1b0be
Preserve nominal ACP session identity through goal refresh callers
trissim Oct 30, 2026
a8bc0fb
Close ACP session contracts at their effect and connection owners
trissim Oct 30, 2026
e8921ba
Retain the room ledger and stop goal refreshes delaying inbox wakes
trissim Oct 30, 2026
1c2cfcf
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
2a39236
Require nominal metadata identity and declare backend inspection
trissim Oct 30, 2026
2327a05
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
546fb97
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
1233f1a
Retain coordinator reader owners and delete per-call opener
trissim Oct 30, 2026
86d106f
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
492d51c
Keep reader acquisition inside its SQLite refusal boundary
trissim Oct 30, 2026
58c2ebf
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
a23d334
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
3532749
Retain owner incarnation separately from ACP attachments
trissim Oct 30, 2026
cd60597
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
a6d21b0
Close backend query imports and refine native replies at their declar…
trissim Oct 30, 2026
1598bda
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
001b608
Separate unattached runtime startup from attached request capabilities
trissim Oct 30, 2026
9fb2e50
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
45724f2
Merge remote-tracking branch 'origin/main' into work/slot-activity--P958
trissim Oct 30, 2026
c0ddfb5
Put snapshot conflicts and input refusal on their owning contracts
trissim Oct 30, 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
52 changes: 22 additions & 30 deletions src/agent_comms/acp.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Agent communications — ACP server on the official protocol library.

Implements an Agent Client Protocol agent so ACP clients (Toad, Zed, VS Code)
Expand All @@ -20,7 +20,6 @@
import asyncio
import json
import os
import re
import sys
import time
from collections.abc import AsyncIterator, Iterator, Sequence
Expand Down Expand Up @@ -60,8 +59,6 @@
manual_compaction_bridge,
)
from .acp_extension import (
CompactRequest,
decode_request,
encode_updates,
)
from .acp_failure import NativeTurnError
Expand All @@ -79,7 +76,7 @@
from .coordination_database import CoordinationStore
from .cursor_owner import CursorOwner
from .cursor_publication import CursorPublication
from .field_codec import ClaimId, FieldCodec, MalformedRecord, RootId
from .field_codec import AcpSessionId, ClaimId, FieldCodec, RootId
from .image_inputs import prompt_images
from .input_drain import InputDrain
from .config_options import ConfigOptions
Expand Down Expand Up @@ -117,17 +114,17 @@
class SdkSessionConnection(AgentSideConnection, Controller, SessionUpdateSink):
"""The SDK connection and controller publish one Core acceptance answer."""

async def publish_update(self, *, session_id: str, update: BaseModel) -> bool:
async def publish_update(self, *, session_id: AcpSessionId, update: BaseModel) -> bool:
await self.session_update(session_id=session_id, update=update)
return True


def require_current(self, authority: AcpSelectedWriteAuthority, session_id: str) -> None:
def require_current(self, authority: AcpSelectedWriteAuthority, session_id: AcpSessionId) -> None:
if self is not authority.agent.sessions.client:
raise IdentityConflict("Selected write ACP controller changed")

async def request_ui_permission(
self, runner: TurnRunner, session_id: str,
self, runner: TurnRunner, session_id: AcpSessionId,
request: tuple[ToolCallUpdate, list[PermissionOption]],
) -> dict[str, object] | None:
tool_call, options = request
Expand All @@ -143,7 +140,7 @@
)
return None

def departed(self, runtime: RuntimeServer, session_id: str) -> bool:
def departed(self, runtime: RuntimeServer, session_id: AcpSessionId) -> bool:
return False


Expand Down Expand Up @@ -198,9 +195,10 @@
# remains attached. Never restore this binding after a process crash.
self._selected_write_controllers: dict[tuple[str, int], tuple[ClaimId, Controller]] = {}

def _create_runtime_proxy(self, thread: Thread, session_id: str) -> RuntimeProxy:
def _create_runtime_proxy(self, thread: Thread, session_id: AcpSessionId) -> RuntimeProxy:
return RuntimeProxy(
self, session_id, socket_path(self._comms.root, thread.require_process().pid),
self, thread.incarnation, session_id,
socket_path(self._comms.root, thread.require_process().pid),
)

# Private attachment ABI consumed by runtime/compaction. State exists only
Expand Down Expand Up @@ -287,19 +285,11 @@
return await self.receive_prompt(request)

async def receive_prompt(self, request: PromptRequest) -> PromptResponse:
try:
command = decode_request(request.field_meta)
except MalformedRecord as error:
raise RequestError.invalid_params({"reason": str(error)}) from error
# Literal /compact is the external ACP text command; ours uses a record.
text = self._prompt_text(request).strip()
if re.match(r"^/compact(?:\s|$)", text):
self._require_compaction_text(request)
command = CompactRequest(text[len("/compact") :].strip())
with self.refusals():
return await AcpRequestConsumer(self, request).run(command)
session_id = FieldCodec.decode(AcpSessionId, request.session_id)
return await AcpRequestConsumer(self, request, session_id).run()

async def _compact_request(self, session_id: str, instructions: str | None) -> PromptResponse:
async def _compact_request(self, session_id: AcpSessionId, instructions: str | None) -> PromptResponse:
# The owner process's bridge alone owns the turn lock.
if session_id in self.sessions.proxies:
outcome = FieldCodec.decode(
Expand All @@ -314,7 +304,7 @@
)
return manual_compaction_bridge.CompactionReply().reply(outcome)

async def _selected_write_request(self, session_id: str, request) -> PromptResponse:
async def _selected_write_request(self, session_id: AcpSessionId, request) -> PromptResponse:
owner = self.sessions.require(session_id)
controller = self._runtime.controller.get()
attached = controller.attached(self.sessions.client) if controller is not None else None
Expand All @@ -341,9 +331,10 @@
return PromptResponse(stop_reason="end_turn", field_meta=encode_updates(acknowledgement))

async def _prompt_request(
self, request: PromptRequest, command: acp_extension.PromptRequest
self, ingress: AcpRequestConsumer, command: acp_extension.PromptRequest
) -> PromptResponse:
session_id = request.session_id
session_id = ingress.session_id
request = ingress.request
text = self._prompt_text(request)
display_text = command.user_text or text
try:
Expand Down Expand Up @@ -378,7 +369,8 @@
)
try:
return await self.turns.prompt_owned(
request,
session_id,
text=text,
images=images,
display_text=display_text,
input_id=command.input_id,
Expand All @@ -400,7 +392,7 @@
)

async def emit_session_identity(
self, session_id: str, name: str, client: SessionUpdateSink,
self, session_id: AcpSessionId, name: str, client: SessionUpdateSink,
) -> None:
"""Let a subscriber identify its owner before potentially long replay."""
await client.publish_update(
Expand All @@ -420,24 +412,24 @@

async def cancel(self, session_id: str, **kwargs: JsonValue) -> None:
with self.refusals():
return await self.turns.cancel(session_id, **kwargs)
return await self.turns.cancel(FieldCodec.decode(AcpSessionId, session_id), **kwargs)

async def close_session(self, session_id: str, **kwargs: JsonValue):
with self.refusals():
return await self.sessions.close_session(session_id)
return await self.sessions.close_session(FieldCodec.decode(AcpSessionId, session_id))

async def set_config_option(
self, config_id: str, session_id: str, value: str | bool, **kwargs: JsonValue
) -> SetSessionConfigOptionResponse:
with self.refusals():
return await self.sessions.config.set_option(config_id, session_id, value)
return await self.sessions.config.set_option(config_id, FieldCodec.decode(AcpSessionId, session_id), value)

async def authenticate(self, method_id: str, **kwargs: JsonValue) -> None:
raise RequestError.auth_required({"reason": "agent-comms requires no authentication"})

# ─── Helpers ─────────────────────────────────────────────────────────────

async def _drain_private_nk(self, session_id: str, wire_root_id: RootId) -> int:
async def _drain_private_nk(self, session_id: AcpSessionId, wire_root_id: RootId) -> int:
"""Run a selected private wake for this ACP session.

This explicitly configured path reuses the reviewed one-shot native
Expand Down
9 changes: 5 additions & 4 deletions src/agent_comms/acp_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@
FailureSource,
UnrecordedFailureSource,
)
from .agent_backend import SendNow, TurnInbox
from .turn_inbox import SendNow, TurnInbox
from .agent_events import CompactionEvent
from .child_process import ProcessIdentity
from .context_reading import ContextReading
from .declared_family import DeclaredFamily
from .external_transcript import DraftRow
from .field_codec import (
AcpSessionId,
AssignmentId,
ClaimId,
AcceptsNull,
Expand Down Expand Up @@ -435,7 +436,7 @@ def projection(self, queued, restored) -> QueueProjection:
def validate_scope(self, projection: QueueProjection) -> None:
projection.without_scope()

def start_revision(self, inputs: InputDrain, session_id: str) -> int | None:
def start_revision(self, inputs: InputDrain, session_id: AcpSessionId) -> int | None:
return None

def accepts_request(self, projection: QueueProjection) -> bool:
Expand Down Expand Up @@ -474,7 +475,7 @@ def projection(self, queued, restored) -> QueueProjection:
def validate_scope(self, projection: QueueProjection) -> None:
pass

def start_revision(self, inputs: InputDrain, session_id: str) -> int:
def start_revision(self, inputs: InputDrain, session_id: AcpSessionId) -> int:
return inputs.next_revision(session_id)

def accepts_request(self, projection: QueueProjection) -> bool:
Expand Down Expand Up @@ -753,7 +754,7 @@ def draft_text(self) -> str | None:
@abstractmethod
def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: ...

async def publish_acceptance(self, inputs: InputDrain, session_id: str) -> None:
async def publish_acceptance(self, inputs: InputDrain, session_id: AcpSessionId) -> None:
await inputs.emit_queue_state(session_id)


Expand Down
56 changes: 35 additions & 21 deletions src/agent_comms/acp_request_consumer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Declared owner commands at the ACP boundary; shared behavior is inherited."""

import re

from metaclass_registry.mro_dispatch import (
MroDispatch,
handles,
Expand All @@ -14,70 +16,82 @@
CompactRequest,
SelectedWriteRequest,
SendNowRequest,
decode_request,
encode_request,
)
from .field_codec import AcpSessionId, MalformedRecord


class AcpRequestConsumer(MroDispatch):

def __init__(self, agent, request: PromptRequest):
self.agent, self.request = agent, request
def __init__(self, agent, request: PromptRequest, session_id: AcpSessionId):
self.agent, self.request, self.session_id = agent, request, session_id
self.response = None

async def run(self, request):
async def run(self):
try:
request = decode_request(self.request.field_meta)
except MalformedRecord as error:
raise Refused.invalid_params({"reason": str(error)}) from error
# Literal /compact is the external ACP text command; ours uses a record.
text = self.agent._prompt_text(self.request).strip()
if re.match(r"^/compact(?:\s|$)", text):
self.agent._require_compaction_text(self.request)
request = CompactRequest(text[len("/compact") :].strip())
await self.dispatch(request)
if self.response is None:
raise RuntimeError("Declared ACP request has no response handler")
return self.response

async def forward(self, request):
forwarded = self.request.model_copy(
update={"field_meta": {**(self.request.field_meta or {}), **encode_request(request)}}
update={
"session_id": self.session_id,
"field_meta": {**(self.request.field_meta or {}), **encode_request(request)},
}
)
result = await self.agent.sessions.proxies[self.request.session_id].request(
result = await self.agent.sessions.proxies[self.session_id].request(
"prompt", request=forwarded
)
return PromptResponse.model_validate(result)

@handles(acp_extension.PromptRequest)
async def prompt_request(self, request):
if self.request.session_id in self.agent.sessions.proxies:
if self.session_id in self.agent.sessions.proxies:
self.response = await self.forward(request)
return
self.response = await self.agent._prompt_request(self.request, request)
self.response = await self.agent._prompt_request(self, request)

@handles(CompactRequest)
async def compact(self, request):
self.agent._require_compaction_text(self.request)
if self.agent._prompt_text(self.request).strip() and not self.agent._prompt_text(
self.request
).strip().startswith("/compact"):
if (
self.agent._prompt_text(self.request).strip()
and not self.agent._prompt_text(self.request).strip().startswith("/compact")
):
raise Refused.invalid_params({"reason": "Compaction metadata requires blank text"})
self.response = await self.agent._compact_request(
self.request.session_id, request.instructions
)
self.response = await self.agent._compact_request(self.session_id, request.instructions)

@handles(SelectedWriteRequest)
async def selected_write(self, request):
if self.request.prompt:
raise Refused.invalid_params({"reason": "Selected write requires no prompt"})
if self.request.session_id in self.agent.sessions.proxies:
if self.session_id in self.agent.sessions.proxies:
self.response = await self.forward(request)
return
self.response = await self.agent._selected_write_request(self.request.session_id, request)
self.response = await self.agent._selected_write_request(self.session_id, request)

@handles(ClearQueueRequest)
async def clear(self, request):
if self.request.session_id in self.agent.sessions.proxies:
await self.agent.sessions.proxies[self.request.session_id].request("clear_queue")
if self.session_id in self.agent.sessions.proxies:
await self.agent.sessions.proxies[self.session_id].request("clear_queue")
else:
await self.agent.inputs.clear_queued_inputs(self.request.session_id)
await self.agent.inputs.clear_queued_inputs(self.session_id)
self.response = PromptResponse(stop_reason="end_turn")

@handles(SendNowRequest)
async def send_now(self, request):
if self.request.session_id in self.agent.sessions.proxies:
if self.session_id in self.agent.sessions.proxies:
self.response = await self.forward(request)
return
await self.agent.inputs.send_now(self.request.session_id)
await self.agent.inputs.send_now(self.session_id)
self.response = PromptResponse(stop_reason="end_turn")
15 changes: 9 additions & 6 deletions src/agent_comms/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,17 @@ def observe_recipients(
snapshot: RegistrySnapshot,
) -> Mapping[ThreadLookup, RecipientActivity]:
"""Acquire one bounded window's recipients from the original sends."""
observations: dict[ThreadLookup, RecipientActivity] = {}
selected = {}
for original, recipient in recipients:
lookup = recipient.recipient_lookup
if lookup in observations:
continue
selected = snapshot.recipient(recipient, original)
observations[lookup] = selected.activity(self, snapshot)
return observations
if lookup not in selected:
selected[lookup] = snapshot.recipient(recipient, original)
captured = self.all_activity(
(thread for recipient in selected.values() for thread in recipient.activity_threads()),
snapshot=snapshot,
)
return {lookup: recipient.activity(captured, snapshot)
for lookup, recipient in selected.items()}

def _emit_activity(self, activity: Activity) -> None:
snapshot = self.registry.snapshot()
Expand Down
Loading
Loading