Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
057b259
Use one submission lifetime for native, mailbox and channel input
trissim Oct 31, 2026
3767a30
Declare the shared queue-entry capability instead of assuming a widget
trissim Oct 31, 2026
e65d306
Settle submissions through worker completion before view retirement
trissim Nov 1, 2026
4f0de4b
Exercise submission failures through the foreign ACP peer
trissim Nov 1, 2026
2cbbfb7
Preserve ACP outcomes through submission settlement
trissim Nov 1, 2026
01caf67
Keep request outcomes beside their captured authority
trissim Nov 1, 2026
372abb9
Observe composer readiness after source acknowledgement
trissim Nov 1, 2026
1182384
Prepare the real DM source before holding its SQLite writer
trissim Nov 1, 2026
4baae67
Settle failures from input evidence and retain the original request
trissim Nov 1, 2026
364b379
Bind the late-ack control through the actual SDK session response
trissim Nov 1, 2026
830c201
Delete the failure-event draft recovery authority
trissim Nov 1, 2026
72083de
Prepare private storage before constructing fixture registrations
trissim Nov 1, 2026
9369a9d
Exercise shared BUS submission on external and channel sources
trissim Nov 1, 2026
e87acf3
Read the registered owner instead of guessing native process text
trissim Nov 1, 2026
884e5e3
Declare read-only presentation and required ACP log construction
trissim Nov 1, 2026
5e78caa
Keep native capabilities on ACP agents and acceptance on events
trissim Nov 1, 2026
deab798
Keep ordinary event acceptance independent of native state
trissim Nov 1, 2026
d86c4ed
Make coordination attachments own thread operations and source checks
trissim Nov 1, 2026
2278adb
Use captured Core project targets and owner-bound reloads
trissim Nov 2, 2026
18979bc
Capture ACP launches and keep response work on its session
trissim Nov 2, 2026
2acc8a4
Keep event identities distinct and conversation handlers on their owner
trissim Nov 2, 2026
45655fb
Retain admitted publication through close and preserve session isolation
trissim Nov 2, 2026
53d9b9e
Call the existing process-borrowing method in shared-close fixture
trissim Nov 2, 2026
9d53b6f
Declare unstable SDK support on the shared-session peer
trissim Nov 2, 2026
74c6d0c
Name physical thread starts and in-flight work plainly
trissim Nov 2, 2026
d1b4998
Keep submission and captured-read parameter contracts explicit
trissim Nov 2, 2026
69c4213
Construct SDK control content at its existing controller owner
trissim Nov 2, 2026
fe6116f
Declare committed-message presentation on the submission family
trissim Nov 2, 2026
e3f484a
Own the shared input history worker contract
trissim Nov 2, 2026
b5cf248
Let the BUS conversation own its submission binding
trissim Nov 2, 2026
bdaece4
Own acquired ACP session identity on its binding
trissim Nov 2, 2026
ec20f4b
Declare thread-interest and notification identity contracts
trissim Nov 2, 2026
e7e4092
Keep title policy and SDK resume spelling with their owners
trissim Nov 2, 2026
ebaad6c
Merge commit 'e7e409268d3469f697f0a69149933f529dca11b0' into batch/pi…
trissim Nov 2, 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
227 changes: 151 additions & 76 deletions src/toad/acp/agent.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import asyncio
from dataclasses import replace
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from collections.abc import Container
from typing import TYPE_CHECKING

import rich.repr
from agent_comms.acp_extension import (
Expand All @@ -10,13 +12,12 @@
PromptRequest,
)
from agent_comms.context_reading import UnmeasuredContext
from agent_comms.nominal.field_codec import FieldCodec, GoalId
from agent_comms.nominal.field_codec import AcpSessionId, FieldCodec, GoalId, InputId
from agent_comms.goal_actions import GoalAction
from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution
from agent_comms.goals import Goal, GoalDeclaration, NoGoal
from agent_comms.goal_presentation import GoalSnapshot
from agent_comms.goals import Goal, GoalDeclaration
from agent_comms.goal_history import RecordedGoalHistory
from agent_comms.order_tools import OwnerGoalRead
from agent_comms.ui_model.observation import ReadOwnerOperation
from agent_comms.thread_presentation import ThreadPresentation

from toad import paths
from toad.acp.agent_configuration import (
Expand All @@ -26,29 +27,37 @@
from toad.acp.agent_controller import AgentController
from toad.acp.agent_log import AgentLog
from toad.acp.agent_process import AgentProcess
from toad.acp.agent_session import AgentSession
from toad.acp.agent_session import AgentSession, SessionDisposition
from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation
from toad.acp.client_files import FileClientRequestOwner
from toad.acp.client_session import ClientSessionRequest
from toad.acp.comms_updates import (
CommsUpdateConsumer,
NoPendingThreadRead,
PendingThreadRead,
ThreadRead,
)
from toad.acp.comms_updates import CommsUpdateConsumer
from toad.acp.coordination_attachment import CoordinationAttachment, CoordinatedThread
from toad.acp.thread_read import ThreadRead
from toad.acp.context_measurement import ContextMeasurement
from toad.acp.permission_controller import PermissionController
from toad.acp.projection_attachment import CursorAttachment
from toad.acp.queue_attachment import QueueAttachment
from toad.acp.session_updates import SessionNotificationOwner
from toad.acp.status import PromptResult
from toad.acp.tool_calls import SessionToolCalls
from toad.acp.transcript_reader import CoordinationTranscriptReader
from toad.agent import AgentBase
from toad.agent_presentation import ACPAgentPresentation
from toad.agent_schema import AgentDefinition
from toad.core import events as core_events
from toad.conversation_submission import (
PromptInputSubmission, ConversationSubmissions, NoSend, SendPending, SendState, SubmissionExecution,
)
from toad.platform import host

if TYPE_CHECKING:
from agent_comms.session_load import ExistingSessionLoadPlan
from toad.core_event_carrier import CoreEventMessage
from toad.screens.main import MainScreen
from toad.surface_binding import SurfaceBinding
from toad.widgets.conversation import Conversation


def generate_datetime_filename(
prefix: str, suffix: str, datetime_format: str | None = None
Expand Down Expand Up @@ -101,13 +110,17 @@
project_root: Project root path.
command: Command to launch agent.
"""
self.definition = agent
super().__init__(project_root)
self.process = AgentProcess(self)
self.presentation = ACPAgentPresentation(self)
log_filename = generate_datetime_filename(self.definition.name, ".txt")
if (log_path := AgentLog.selected()) is None:
log_path = paths.get_log() / log_filename
self._presentation = ACPAgentPresentation(self, log_path)
self.permissions = PermissionController(self)
self.coordination_attachment: CoordinationAttachment = CoordinationAttachment()
self.controller = AgentController(self, transcripts)
self.tools = SessionToolCalls(self)
self.definition = agent
self.session_id = session_id
self.updates = SessionNotificationOwner(self)
self.files = FileClientRequestOwner(self)
Expand All @@ -116,13 +129,49 @@
self._private_cursor = CursorAttachment()
self._private_cursor_sequence = 0
self.queue_attachment = QueueAttachment()
log_filename: str = generate_datetime_filename(f"{agent.name}", ".txt")
if (log_path := AgentLog.selected()) is not None:
self.presentation.log_path = log_path
else:
self.presentation.log_path = paths.get_log() / log_filename
self.context_measurement = ContextMeasurement.saved(UnmeasuredContext())

@property
def presentation(self) -> ACPAgentPresentation:
return self._presentation

@property
def session_disposition(self) -> SessionDisposition:
return self.session.disposition

def accepts_publication(
self, message: CoreEventMessage, session_id: AcpSessionId | None
) -> bool:
return message.publisher is self and self.session_id == session_id

def borrowed_processes(self) -> tuple[AgentProcess, ...]:
return (self.process,)

async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None:
await self.session.reconnect(plan)

async def change_project(self, view: Conversation, path: Path) -> None:
from textual.content import Content

project = await self.update_project(str(path))
view.feedback.flash(Content(f"Project changed to {project}"), style="success")

def request_compaction(self, view: Conversation, instructions: str | None) -> None:
view.compact_context(instructions)

async def project_changed(self, path: Path) -> None:
from toad.db import DB

await super().project_changed(path)
if (session_pk := self.session.pk) is not None:
await DB().session_update_project(session_pk, path)

def refresh_context(self, view: Conversation) -> None:
view.thread_status = self.context_measurement.status()

async def compact_context(self, instructions: str | None) -> None:
await self.controller.compact_context(instructions)

@property
def command(self) -> str | None:
"""The command used to launch the agent, or `None` if there isn't one."""
Expand Down Expand Up @@ -157,17 +206,20 @@
await self.process.stop(self)


async def send_prompt(self, prompt: str, *, request: PromptRequest | None = None):
def begin_submission(self, owner: ConversationSubmissions, submission: PromptInputSubmission):
return owner.start(NativeSubmissionExecution(owner, submission, self))

async def send_prompt(self, prompt: str, *, request: PromptRequest | None = None) -> PromptResult:
"""Submit through AgentBase's contract; the controller runs the request."""
return await self.controller.submit(prompt, request=request)


@property
def session_id(self):
def session_id(self) -> AcpSessionId | None:
return self.controller.session.session_id

@session_id.setter
def session_id(self, value) -> None:
def session_id(self, value: str | None) -> None:
self.controller.bind_session(value)

@property
Expand All @@ -178,24 +230,26 @@
def current_turn(self):
return self.presentation.turns.owner

async def retire_surface(self, surface) -> None:
async def retire_surface(self, surface: Conversation) -> None:
if self.controller.surface.owns(surface):
self.detach_surface(surface)
await self.stop()

def attach_surface(self, surface) -> None:
def attach_surface(self, surface: SurfaceBinding) -> None:
self.controller.attach(surface)

def detach_surface(self, surface) -> None:
def detach_surface(self, surface: Conversation) -> None:
self.controller.detach(surface)

@property
def coordination(self) -> CoordinationChangedUpdate | None:
return self.controller.coordination

@coordination.setter
def coordination(self, value: CoordinationChangedUpdate | None) -> None:
self.controller.coordination = value
def receive_coordination(self, update: CoordinationChangedUpdate, session_id: AcpSessionId) -> None:
"""Consume one original publication and refresh its derived projections."""
self.controller.transcripts.access.selected(update.wire_root)
self.coordination_attachment = CoordinatedThread(update)
self.project_root_path = update.worktree.path
self.context_measurement = ContextMeasurement.saved(update.context_usage)
self.update_status_line()
self.coordination_attachment.publish(self, session_id)
self.session.coordinated_title(update.title)

def _post_queue_view(self, starts: tuple[InputStartedUpdate, ...] = ()) -> None:
self.controller.surface.sync_queue()
Expand All @@ -204,7 +258,10 @@

def _post_private_cursor(self) -> None:
self._private_cursor_sequence += 1
self.events.publish(core_events.CommsUpdated(CursorPresentation(self._private_cursor.status), self.session_id, self._private_cursor_sequence))
self.events.publish(core_events.CommsUpdated(
CursorPresentation(self._private_cursor.status, self._private_cursor_sequence),
self.session_id,
))

def _invalidate_attachment_views(self) -> None:
self._private_cursor.invalidate()
Expand Down Expand Up @@ -232,18 +289,7 @@

async def get_goal_snapshot(self) -> GoalSnapshot:
"""The owner's goal snapshot, read by the observation service; its turn settles here."""
from .comms_updates import OwnerSnapshotConsumer
coordination = self.coordination
if coordination is None:
return GoalSnapshot(NoGoal(), IdleGoalExecution())
authority = ClientSessionRequest(self, self.session_id)
consumer = OwnerSnapshotConsumer(self, self.session_id, turn_token=self.presentation.turns.sequence)
snapshot = await self.controller.transcripts.read(
ReadOwnerOperation(coordination.wire_root, coordination.thread, operation=OwnerGoalRead()))
self.controller.require_owner(coordination, authority)
for fact in snapshot.meta.batch.updates:
consumer.dispatch_sync(fact)
return snapshot
return await self.coordination_attachment.goal_snapshot(self)

async def get_goal_history(self, goal_id: GoalId) -> RecordedGoalHistory:
result = await self.controller.request_owner("goal_history", goal_id=goal_id)
Expand All @@ -255,43 +301,22 @@
)
return FieldCodec.decode(Goal, result["goal"])

@property
def automatic_titles(self) -> bool:
return self.coordination_attachment.automatic_titles

def observed_thread(self) -> tuple[str, str] | None:
coordination = self.coordination
if coordination is None or coordination.wire_root is None or coordination.thread.name is None:
return None
return coordination.wire_root, coordination.thread.name
return self.coordination_attachment.observed_thread()

def thread_read(self) -> ThreadRead:
"""Capture, before a presentation read, what its result must still match."""
from .comms_updates import OwnerSnapshotConsumer
return self.coordination_attachment.thread_read(self)

coordination = self.coordination
if coordination is None:
return NoPendingThreadRead()
return PendingThreadRead(self, coordination, ClientSessionRequest(self, self.session_id),
OwnerSnapshotConsumer(self, self.session_id,
turn_token=self.presentation.turns.sequence))

async def observe_thread_presentation(self, presentation) -> None:
await self.session.observe_owner(presentation)
async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None:
await self.coordination_attachment.observe_owner(self, presentation)

async def update_project(self, path: str) -> str:
if (self.coordination.wire_root if self.coordination else None) is None or (
self.coordination.thread.name if self.coordination else None
) is None:
raise ValueError("Project changes require an agent-comms thread.")
authority = ClientSessionRequest(self, self.session_id)
coordination = self.coordination
comms = await self.controller.transcripts.service(coordination.wire_root)
authority.require()
result = await asyncio.to_thread(
comms.threads.set_project, coordination.thread.name, path,
)
authority.require()
self.coordination = replace(self.coordination, worktree=result.current)
self.project_root_path = Path(result.current)
self.events.publish(core_events.CommsUpdated(self.coordination, self.session_id))
return result.current
return await self.coordination_attachment.update_project(self, path)

async def update_goal(self, action: type[GoalAction], text: str = "") -> GoalDeclaration:
result = await action.submit_control(
Expand All @@ -306,3 +331,53 @@

async def set_session_name(self, name: str) -> None:
await self.session.set_name(name)


@dataclass(frozen=True)
class NativeInputPresented(SendState):
started: InputStartedUpdate

def response_finished(self, execution: SubmissionExecution) -> None:
execution.enter(NoSend())


class NativeSubmissionExecution(SubmissionExecution):
"""ACP owns its captured request and native acknowledgement, not the shared transaction."""

def __init__(self, owner: ConversationSubmissions, submission: PromptInputSubmission, agent: Agent):
super().__init__(owner, submission)
self.agent = agent
self.authority = ClientSessionRequest(agent, agent.session_id)
self.scope = agent.queue_attachment.scope
self.request = submission.request

@property
def current(self):
view = self.owner.view
return (view.agent_binding is self.agent and not self.authority.retired
and self.agent.queue_attachment.accepts_request(self.scope))

async def feedback(self):
await self.submission.feedback(self)

def accepted(self, accepted: Container[InputId]) -> bool:
return self.request.input_id in accepted or not self.agent.presentation.uses_managed_turns

def native_input_presented(self, started: InputStartedUpdate) -> None:
if self.current and started.started_request(self.request.input_id):
self.state.presented(self, NativeInputPresented(started))

async def deliver(self):
view = self.owner.view
local = not self.agent.presentation.uses_managed_turns
if local:
view.turns.start_client()
self.enter(SendPending())
outcome = await self.agent.send_prompt(self.submission.text, request=self.request)
outcome.settle_submission(self)
if self.current and local:
view.call_later(self.complete, outcome)

async def complete(self, outcome: PromptResult):
if self.current:
await outcome.finish_turn(self.owner.view)
Loading
Loading