Skip to content
Merged
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
201 changes: 135 additions & 66 deletions src/toad/acp/agent_controller.py

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions src/toad/acp/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
from pathlib import Path

from acp import schema
from acp.helpers import ContentBlock
from agent_comms.field_codec import FieldCodec
from agent_comms.image_inputs import ImageInput, prompt_images

from toad.clipboard_image import attachment_directory
from toad.file_kind import FileKind
from toad.prompt.extract import extract_paths_from_prompt
from toad.prompt.resource import load_resource, ResourceError
from toad.clipboard_image import attachment_directory
from agent_comms.image_inputs import ImageInput, prompt_images
from toad.prompt.resource import ResourceError, load_resource


def build(project_path: Path, prompt: str) -> list[object]:
def build(project_path: Path, prompt: str) -> list[ContentBlock]:
"""Build the prompt structure and extract paths with the @ syntax.

Args:
Expand All @@ -36,7 +39,7 @@ def build(project_path: Path, prompt: str) -> list[object]:
uri = resource.path.resolve().as_uri()
if resource.mime_type.startswith("image/") and resource.data is not None:
image = ImageInput(base64.b64encode(resource.data).decode("ascii"), resource.mime_type)
prompt_content.append({**image.to_rpc(), "uri": uri})
prompt_content.append({**FieldCodec.encode_record(image), "uri": uri})
image_spans.append((start, end))
elif resource.text is not None:
prompt_content.append(
Expand Down Expand Up @@ -68,4 +71,6 @@ def build(project_path: Path, prompt: str) -> list[object]:
if image_spans:
prompt_content[0]["text"] = prompt.strip()
prompt_images(prompt_content) # Validate the combined image count/size too.
return schema.PromptRequest.model_validate({"sessionId": "attachments", "prompt": prompt_content}, strict=True).prompt
return schema.PromptRequest.model_validate(
{"sessionId": "attachments", "prompt": prompt_content}, strict=True
).prompt
102 changes: 65 additions & 37 deletions src/toad/agent_binding.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
"""The agent itself is a bound connection; a saved view may be unbound."""

from __future__ import annotations

from abc import ABC, abstractmethod
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING

from agent_comms.thread_presentation import ThreadPresentation
from textual.content import Content

from toad.acp.agent_configuration import AgentConfiguration
from toad.acp.agent_session import UnopenedSessionDisposition
from toad.agent_presentation import LocalAgentPresentation
from toad.agent_presentation import AgentPresentation, LocalAgentPresentation

if TYPE_CHECKING:
from agent_comms.goal_actions import GoalAction
from agent_comms.session_load import ExistingSessionLoadPlan

from toad.conversation_submission import (
AgentInputSubmission,
ConversationSubmissions,
SubmissionStart,
)
from toad.conversation_turn import TurnOwner
from toad.core_event_carrier import CoreEventMessage
from toad.screens.main import MainScreen
from toad.session_observation import SessionObservation
from toad.session_presentation import OperationalSessionSources
from toad.widgets.conversation import Conversation, ConversationSessionBinding


class AgentConnection(ABC):
configuration: AgentConfiguration
presentation: AgentPresentation

@abstractmethod
async def observe_thread_presentation(
self, presentation: ThreadPresentation | None
) -> None: ...
async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: ...

def thread_read(self):
from toad.acp.comms_updates import NoPendingThreadRead

return NoPendingThreadRead()

def model_info(self, session_name: str) -> Content:
Expand All @@ -30,32 +51,39 @@ def model_info(self, session_name: str) -> Content:
return Content(model.name + (f" · {level}" if level else ""))

@abstractmethod
def attach_view(self, view) -> None: ...
def attach_view(self, view: ConversationSessionBinding) -> None: ...

@abstractmethod
def refresh_projection(self, view) -> None: ...
def refresh_projection(self, view: ConversationSessionBinding) -> None: ...

@abstractmethod
def start_session(self, view) -> None: ...
def start_session(self, view: ConversationSessionBinding) -> None: ...

@abstractmethod
def start_agent(self, sources, view) -> None: ...
def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: ...

@abstractmethod
def update_view(self, view) -> None: ...
def update_view(self, view: ConversationSessionBinding) -> None: ...

@abstractmethod
def connected(self, view) -> None: ...
def connected(self, view: ConversationSessionBinding) -> None: ...

@abstractmethod
def accepts_message(self, message) -> bool: ...
def accepts_message(self, message: CoreEventMessage) -> bool: ...

@abstractmethod
def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission) -> SubmissionStart: ...

@abstractmethod
def accepts_failed_input(self, message: CoreEventMessage) -> bool: ...

@abstractmethod
def matches_agent(self, agent) -> bool: ...


class NoAgentConnection(AgentConnection):
"""A real saved view before it acquires any native connection."""

ready = False
automatic_titles = True
available_modes = ()
Expand All @@ -73,25 +101,25 @@ def configuration(self):
def get_info(self) -> str:
return ""

async def retire_surface(self, view) -> None:
async def retire_surface(self, view: Conversation) -> None:
"""There is no native surface to retire."""

def attach_view(self, view) -> None:
def attach_view(self, view: ConversationSessionBinding) -> None:
"""There is no event stream to subscribe to."""

def refresh_projection(self, view) -> None:
def refresh_projection(self, view: ConversationSessionBinding) -> None:
"""There is no native projection to read."""

def start_session(self, view) -> None:
def start_session(self, view: ConversationSessionBinding) -> None:
view.start_unbound_session()

def start_agent(self, sources, view) -> None:
def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None:
sources.create_agent(view)

def update_view(self, view) -> None:
def update_view(self, view: ConversationSessionBinding) -> None:
view.agent_info = Content.styled("shell")

def connected(self, view) -> None:
def connected(self, view: ConversationSessionBinding) -> None:
"""There is no connected-agent notification."""

async def set_session_name(self, name: str) -> None:
Expand All @@ -100,71 +128,71 @@ async def set_session_name(self, name: str) -> None:
async def stop(self) -> None:
"""There is no agent process to stop."""

def detach_surface(self, view) -> None:
def detach_surface(self, view: Conversation) -> None:
"""There is no agent subscription to detach."""

def accepts_message(self, message) -> bool:
def accepts_message(self, message: CoreEventMessage) -> bool:
return False

def matches_agent(self, agent) -> bool:
return False

async def project_changed(self, path) -> None:
async def project_changed(self, path: Path) -> None:
"""There is no native session project to persist."""

def refresh_context(self, view) -> None:
def refresh_context(self, view: Conversation) -> None:
"""There is no native measurement to display."""

async def set_model(self, model_id) -> str:
async def set_model(self, model_id: str) -> str:
return "No agent is bound yet"

async def set_mode(self, mode_id) -> str:
async def set_mode(self, mode_id: str) -> str:
return "No agent is bound yet"

async def compact_context(self, instructions) -> None:
"""No native context exists to compact."""

async def cancel_turn(self, view) -> None:
async def cancel_turn(self, view: Conversation) -> None:
"""No native turn exists to cancel."""

def begin_submission(self, owner, submission):
def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission):
return owner.unbound(submission)

def accepts_failed_input(self, message) -> bool:
def accepts_failed_input(self, message: CoreEventMessage) -> bool:
return False

def send_now(self, owner) -> None:
def send_now(self, owner: ConversationSubmissions) -> None:
"""No remote queue is bound."""

async def get_goal_history(self, goal_id):
async def get_goal_history(self, goal_id: str):
return ()

async def update_goal(self, action, text: str):
async def update_goal(self, action: type[GoalAction], text: str):
raise ValueError("Persistent goals require an agent-comms session")

def cancel_allowed(self, turn) -> bool:
def cancel_allowed(self, turn: TurnOwner) -> bool:
return False

async def cancel(self) -> bool:
return False

def matches_session(self, session_id) -> bool:
def matches_session(self, session_id: str) -> bool:
return False

def borrowed_processes(self):
return ()

async def reconnect_session(self, source, plan) -> None:
async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None:
source.presentation.sources.attach_started(source, plan)

async def change_project(self, view, path) -> None:
async def change_project(self, view: Conversation, path: Path) -> None:
view.feedback.flash("Project changes require an agent-comms session", style="error")

def request_compaction(self, view, instructions) -> None:
def request_compaction(self, view: Conversation, instructions) -> None:
view.feedback.flash("Context compaction requires an agent-comms session", style="error")

async def observe_thread_presentation(self, presentation) -> None:
async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None:
"""There is no connected agent to inform."""

def invalidate_observation(self, observation) -> None:
def invalidate_observation(self, observation: SessionObservation) -> None:
"""No native source exists to schedule a read against."""
Loading
Loading