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
5 changes: 2 additions & 3 deletions src/toad/about.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import platform
from string import Template

from pathlib import Path
from toad.preferences import SavedSettings
from toad.terminal_attention import TerminalAttention
from toad import paths
Expand Down Expand Up @@ -78,7 +77,7 @@
)


def render(root: Path) -> str:
def render(core: str) -> str:
"""Render about markdown.

Returns:
Expand All @@ -95,7 +94,7 @@ def render(root: Path) -> str:
"COLORTERM": os.environ.get("COLORTERM", ""),
"COMMS_AGENT_ARGS": shlex.join(InheritedArguments().configured().argv),
"COMMS_AGENT_BIN": ConfiguredBinary().configured(),
"COMMS_ROOT": str(root),
"COMMS_ROOT": core,
"CONFIG": config,
"DATA_PATH": paths.get_data(),
"LOG_PATH": paths.get_log(),
Expand Down
9 changes: 4 additions & 5 deletions src/toad/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from toad.core import events as core_events
from toad.workspace_sessions import WorkspaceSessionShutdown
from inspect import isabstract
from toad.comms_root import CoordinationAccess
from toad.comms_root import CoreConnection

from toad.thread_actions import ThreadActions
from toad.widgets.comms_transfer import Transfers
Expand All @@ -18,7 +18,6 @@
from typing import TYPE_CHECKING, Callable, Never, TypeVar, cast

from agent_comms.acp_extension import TranscriptChangedUpdate
from agent_comms.launch_root import LaunchRoot
from agent_comms.launch_runtime import LaunchRuntime
from agent_comms.repo import Checkout
from agent_comms.root_runtime import RootRuntime
Expand Down Expand Up @@ -151,7 +150,7 @@ def __init__(
self,
agent_binding: AgentBinding,
project_dir: Checkout,
launch_root: LaunchRoot,
connection: CoreConnection,
mode: str | None = None,
agent_session_id: str | None = None,
) -> None:
Expand All @@ -160,7 +159,7 @@ def __init__(
Args:
agent_binding: The agent selected at startup.
project_dir: Project directory.
launch_root: Coordination root selected by the launch owner.
connection: Core transport selected by the launch owner.
mode: Initial mode.
"""
from toad.render_processes import RenderProcessPool
Expand Down Expand Up @@ -191,7 +190,7 @@ def __init__(
db=DB(),
)
self._session_tracker = SessionTracker(self.tab_order)
self.coordination_access = CoordinationAccess(self, launch_root)
self.coordination_access = connection.open_access(self)

from toad.file_kind import FileKind
FileKind.prepare_terminal()
Expand Down
16 changes: 8 additions & 8 deletions src/toad/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import click

from agent_comms.nominal.field_codec import FieldCodec
from agent_comms.launch_root import LaunchRoot
from agent_comms.repo import Checkout
from toad.session_scope import AgentAttached, AgentBinding, NoAgent
from toad.comms_root import CoreConnection

if TYPE_CHECKING:
from toad.app import ToadApp
Expand Down Expand Up @@ -78,7 +78,7 @@ def parse_args(self, ctx, args):
from agent_comms.active_route import ActiveRoute

# Select once at the process boundary; apps receive this captured value.
ctx.obj = LaunchRoot.startup(args, ActiveRoute.published_launch)
ctx.obj = CoreConnection.startup(args, ActiveRoute.published_launch)
# Check if first arg is a known subcommand
if not args or args[0] not in self.commands:
# If not a subcommand, prepend the default command name
Expand Down Expand Up @@ -124,7 +124,7 @@ def main(ctx, version):
@click.argument("project_dir", metavar="PATH", required=True)
@click.option("-a", "--agent", metavar="AGENT", default="")
@click.pass_obj
def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"):
def run(connection: CoreConnection, project_dir: str, agent: str = "1"):
"""Run an installed agent."""

project = FieldCodec.decode(Checkout, project_dir).resolved(strict=True)
Expand All @@ -138,7 +138,7 @@ def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"):
mode=binding.startup_mode,
agent_binding=binding,
project_dir=project,
launch_root=launch_root,
connection=connection,
)
run_terminal(app)

Expand All @@ -162,7 +162,7 @@ def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"):
@click.option("-d", "--project-dir", "project_option", metavar="PATH", default=None)
@click.pass_obj
def acp(
launch_root: LaunchRoot,
connection: CoreConnection,
command: str,
title: str | None,
project_dir: str | None,
Expand Down Expand Up @@ -199,7 +199,7 @@ def acp(
app = ToadApp(
agent_binding=AgentAttached(agent_data),
project_dir=FieldCodec.decode(Checkout, project_dir).resolved(strict=True),
launch_root=launch_root,
connection=connection,
agent_session_id=session_id,
)
run_terminal(app)
Expand Down Expand Up @@ -275,12 +275,12 @@ def serve(port: int, host: str, toad_args: tuple[str, ...]) -> None:

@main.command("about")
@click.pass_obj
def about(launch_root: LaunchRoot) -> None:
def about(connection: CoreConnection) -> None:
"""Show about information."""

from toad import about

print(about.render(launch_root.path))
print(about.render(connection.location))


if __name__ == "__main__":
Expand Down
128 changes: 112 additions & 16 deletions src/toad/comms_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@
from dataclasses import dataclass, field, replace
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING, ClassVar, TypeVar
from weakref import WeakKeyDictionary, WeakSet

from agent_comms.message_reference import MessageReference
from agent_comms.child_process import join_retirement
from agent_comms.comms import Comms
from agent_comms.core_access import CoreAccess, LocalCoreAccess
from agent_comms.remote_core import RemoteCoreAccess
from agent_comms.display_references import PendingReferences, UnavailableReferences
from agent_comms.errors import RouteChangedError
from agent_comms.launch_root import LaunchRoot
Expand Down Expand Up @@ -95,6 +96,91 @@
ROUTE_CHECK_SECONDS = 1.0


class CoreConnection(ABC):
"""The selected Core transport: it owns its route selection and opens its observation."""

@staticmethod
def startup(arguments: list[str], published: Callable[[], LaunchRoot]) -> CoreConnection:
"""Select once at the process boundary; a UI profile selects the remote Core."""
if RemoteCoreConnection.argument in arguments:
return RemoteCoreConnection.startup(arguments)
return LocalCoreConnection.from_launch(LaunchRoot.startup(arguments, published))

def open_access(self, app: ToadApp) -> CoordinationAccess:
return CoordinationAccess(app, self)

@property
@abstractmethod
def location(self) -> str:
"""Where this connection's Core is, as shown to the owner."""

@abstractmethod
def route_selection(self) -> RouteSelection: ...

@abstractmethod
def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation:
"""This connection's current observation, or the error that refuses it."""


class LocalCoreConnection(LaunchRoot, CoreConnection):
"""A local connection inherits the selected root and its launch behavior."""

@classmethod
def from_launch(cls, root: LaunchRoot) -> LocalCoreConnection:
return cls(root.path)

@property
def location(self) -> str:
return str(self.path)

def route_selection(self) -> RouteSelection:
return RouteSelection.of(self)

def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation:
"""Capture the current route and open its service, or expose the opening error."""
selected: RouteSelection | UnselectedRoute = UnselectedRoute()
try:
selected = self.route_selection()
return access.require(selected)
except (OSError, ValueError, RuntimeError) as error:
return access.observation.failed(error, selected)


class RemoteObservationNotServed(RuntimeError):
"""The connector answers this UI's Core requests but does not yet carry its observation."""


@dataclass(frozen=True)
class RemoteCoreConnection(RemoteCoreAccess, CoreConnection):
"""The owner's UI profile is the remote connection: Core answers through the connector."""

argument: ClassVar[str] = "--comms-profile"

@classmethod
def startup(cls, arguments: list[str]) -> RemoteCoreConnection:
index = arguments.index(cls.argument)
if index + 1 == len(arguments):
raise ValueError(f"{cls.argument} requires a UI profile path")
connection = cls.read(Path(arguments[index + 1]))
del arguments[index : index + 2]
return connection

@property
def location(self) -> str:
return str(self.endpoint.url)

def route_selection(self) -> RouteSelection:
raise RemoteObservationNotServed(
f"{self.endpoint.url} answers Core requests; local route selection does not apply")

def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation:
return access.observation.adopt(ConnectorObservation(
RemoteObservationNotServed(
f"{self.endpoint.url} does not yet carry the sidebar, status and notification "
"observation; conversation pages are read through its Core requests"),
UnselectedRoute(), connection=self, access=access))


@dataclass(frozen=True)
class UnselectedRoute:
"""Route capture failed before a validated selection existed."""
Expand Down Expand Up @@ -135,12 +221,15 @@ async def current_references(self, access: CoordinationAccess, root: str):
return access.reference_context(root)

def failed(self, error: Exception, selected):
failed = FailedObservation(error, selected, access=self.access)
if failed == self:
return self.adopt(FailedObservation(error, selected, access=self.access))

def adopt[O: Observation](self, observation: O) -> O | Observation:
"""Make ``observation`` current unless it equals this one."""
if observation == self:
return self
self.access.observation = failed
self.access.app.running_loop.call_soon_threadsafe(failed.attach, self.access, self)
return failed
self.access.observation = observation
self.access.app.running_loop.call_soon_threadsafe(observation.attach, self.access, self)
return observation

def stop_updates(self) -> Observation:
self.updates.close()
Expand Down Expand Up @@ -230,6 +319,18 @@ def observe_team(self, wanted: ObserveTeamHud | StopTeamHud):
self.access.events.publish(TeamHudFailed(self.error))


@dataclass(frozen=True, eq=False)
class ConnectorObservation(FailedObservation):
"""Core answers this UI's requests through the connector. Every observed surface
refuses with the declared error until the connector carries the observation."""

connection: RemoteCoreConnection = field(kw_only=True, compare=False)

@property
def core(self) -> CoreAccess:
return self.connection


@dataclass(frozen=True)
class Observing(Observation):
selection: RouteSelection
Expand Down Expand Up @@ -357,9 +458,9 @@ def observe_team(self, wanted: ObserveTeamHud | StopTeamHud):
class CoordinationAccess(MroDispatch):
"""A validated Core route owns cached access, observations and UI write checks."""

def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None:
def __init__(self, app: ToadApp, connection: CoreConnection) -> None:
self.app = app
self.launch_root = launch_root
self.connection = connection
from toad.acp.transcript_reader import CoordinationTranscriptReader

self.transcripts = CoordinationTranscriptReader(self)
Expand Down Expand Up @@ -392,7 +493,7 @@ def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None:
@property
def selection(self) -> RouteSelection:
"""The current route resolved from this app's held launch root."""
return RouteSelection.of(self.launch_root)
return self.connection.route_selection()

def selected(self, source: str | Path) -> RouteSelection:
"""Select this app's route and refuse a foreign source."""
Expand All @@ -403,14 +504,9 @@ def current(self, source: str | Path) -> bool:
return self.selection.root_is_current(source)

def open_current(self) -> Observing | FailedObservation:
"""Capture the current route and open its service, or expose the opening error."""
"""The connection's current observation, or the error that refuses it."""
with self.opening:
selected: RouteSelection | UnselectedRoute = UnselectedRoute()
try:
selected = self.selection
return self.require(selected)
except (OSError, ValueError, RuntimeError) as error:
return self.observation.failed(error, selected)
return self.connection.open_current(self)

def confirm(self, selected: RouteSelection) -> None:
if self.selection != selected:
Expand Down
2 changes: 1 addition & 1 deletion src/toad/slash_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async def apply(self, conversation: Conversation) -> bool:
from toad import about
from toad.widgets.markdown_note import MarkdownNote

markdown = about.render(conversation.app.coordination_access.selection.root)
markdown = about.render(conversation.app.coordination_access.connection.location)
await conversation.post(MarkdownNote(markdown, classes="about"))
conversation.app.copy_to_clipboard(markdown)
conversation.notify(
Expand Down
4 changes: 2 additions & 2 deletions tests/native/test_native_catalog_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(works
with private_catalog_entry() as definition:
from toad.app import ToadApp

def with_catalog_agent(*, launch_root, **arguments):
return ToadApp(launch_root=launch_root,
def with_catalog_agent(*, connection, **arguments):
return ToadApp(connection=connection,
**{**arguments, "agent_binding": AgentAttached(definition)})

async with native_session(workspace, application=with_catalog_agent) as native:
Expand Down
4 changes: 3 additions & 1 deletion tests/support/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ def __init__(self, app: ToadApp) -> None:
def unrun(cls, project: Path, launch_root: LaunchRoot) -> AppAgents:
"""For a test with no app running: agents of an app built on ``project`` and never run."""
from toad.app import ToadApp
from toad.comms_root import LocalCoreConnection

return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project), launch_root=launch_root))
return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project),
connection=LocalCoreConnection.from_launch(launch_root)))

def agent[A: Agent](self, kind: type[A], definition: AgentDefinition, session_id: str | None = None,
**fields: object) -> A:
Expand Down
4 changes: 3 additions & 1 deletion tests/support/core_adapters/human_input_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from toad.agent import AgentBase
from toad.agent_schema import AgentDefinition
from toad.app import ToadApp
from toad.comms_root import LocalCoreConnection
from toad.session_scope import AgentAttached


Expand Down Expand Up @@ -180,7 +181,8 @@ def phase(name):
'accepts_comms_root': True,
})
app = ToadApp(agent_binding=AgentAttached(definition), project_dir=subject.worktree,
agent_session_id=subject.name, launch_root=LaunchRoot.from_path(owner._comms.root))
agent_session_id=subject.name,
connection=LocalCoreConnection.from_launch(LaunchRoot.from_path(owner._comms.root)))
phase('before_actual_toad_run_test')
async with app.run_test(headless=True, size=(160, 44)) as pilot:
phase('actual_toad_run_test_yielded_before_saved_view_ready')
Expand Down
3 changes: 2 additions & 1 deletion tests/support/core_adapters/mcp_permission_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async def disconnected(self):
@asynccontextmanager
async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer]:
from toad.app import ToadApp
from toad.comms_root import LocalCoreConnection
from toad.session_scope import NoAgent

root = Path(artifact_dir).resolve()
Expand All @@ -157,7 +158,7 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer
os.environ.update(env)
try:
app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project"),
launch_root=LaunchRoot.from_path(root / "wire"))
connection=LocalCoreConnection.from_launch(LaunchRoot.from_path(root / "wire")))
async with app.run_test(size=(120, 40)) as pilot:
# Toad starts with no tab open; the observed session is opened here.
definition = AgentDefinition.decode(AGENT_DATA)
Expand Down
Loading
Loading