diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index 6f740e2b5..bab886c5d 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -10,7 +10,7 @@ PromptRequest, ) from agent_comms.context_reading import UnmeasuredContext -from agent_comms.nominal.field_codec import FieldCodec +from agent_comms.nominal.field_codec import FieldCodec, GoalId from agent_comms.goal_actions import GoalAction from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution from agent_comms.goals import Goal, GoalDeclaration, NoGoal @@ -245,7 +245,7 @@ async def get_goal_snapshot(self) -> GoalSnapshot: consumer.dispatch_sync(fact) return snapshot - async def get_goal_history(self, goal_id: str) -> RecordedGoalHistory: + async def get_goal_history(self, goal_id: GoalId) -> RecordedGoalHistory: result = await self.controller.request_owner("goal_history", goal_id=goal_id) return FieldCodec.decode(RecordedGoalHistory, result) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 258fdc2a2..6497fabf1 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -5,9 +5,10 @@ from abc import ABC, abstractmethod from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING +from typing import ClassVar, TYPE_CHECKING from agent_comms.thread_presentation import ThreadPresentation +from agent_comms.errors import GoalRefused from agent_comms.goal_history import RecordedGoalHistory from textual.content import Content @@ -19,6 +20,8 @@ if TYPE_CHECKING: from agent_comms.goal_actions import GoalAction + from agent_comms.goals import Goal, GoalDeclaration + from agent_comms.nominal.field_codec import GoalId from agent_comms.session_load import ExistingSessionLoadPlan from toad.conversation_submission import ( @@ -97,6 +100,17 @@ def accepts_failed_input(self, message: CoreEventMessage) -> bool: ... @abstractmethod def matches_agent(self, agent) -> bool: ... + goal_control_refusal: ClassVar[str] = "Persistent goals require an agent-comms session" + + async def get_goal_history(self, goal_id: GoalId) -> RecordedGoalHistory: + return RecordedGoalHistory.empty() + + async def edit_goal(self, goal: Goal, text: str) -> Goal: + raise GoalRefused(self.goal_control_refusal) + + async def update_goal(self, action: type[GoalAction], text: str = "") -> GoalDeclaration: + raise GoalRefused(self.goal_control_refusal) + class NoAgentConnection(AgentConnection): """A real saved view before it acquires any native connection.""" @@ -178,12 +192,6 @@ def begin_submission(self, owner: ConversationSubmissions, submission: AgentInpu def accepts_failed_input(self, message: CoreEventMessage) -> bool: return False - async def get_goal_history(self, goal_id: str) -> RecordedGoalHistory: - return RecordedGoalHistory.empty() - - async def update_goal(self, action: type[GoalAction], text: str): - raise ValueError("Persistent goals require an agent-comms session") - def cancel_allowed(self, turn: TurnOwner) -> bool: return False diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 21307f419..5657c0144 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -2,20 +2,21 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Iterator +from collections.abc import Awaitable, Iterator from contextlib import contextmanager from typing import ClassVar, TYPE_CHECKING -from weakref import ref +from weakref import ReferenceType, ref from agent_comms.coordination_errors import Refused from agent_comms.errors import GoalRefused from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.goal_actions import ClearGoalAction, GoalAction -from agent_comms.goal_presentation import ControlledExecution, GoalExecution +from agent_comms.goal_presentation import GoalExecution from agent_comms.goal_history import RecordedGoalHistory from toad.agent_binding import AgentConnection if TYPE_CHECKING: + from toad.screens.modal import ModalScreen from toad.widgets.conversation import Conversation @@ -76,30 +77,26 @@ async def apply(cls, session): class ToggleInteraction(GoalInteraction): - label = "Pause" + label = GoalExecution.toggle_label @classmethod - def visible(cls, session): - return session.display.can_control and isinstance(session.execution, ControlledExecution) + def visible(cls, session: GoalSession) -> bool: + return session.display.can_control and session.execution.can_toggle() @classmethod - def caption(cls, session, collapsed): + def caption(cls, session: GoalSession, collapsed: bool) -> str: return session.execution.toggle_label if cls.visible(session) else cls.label @classmethod - async def apply(cls, session): - execution = session.execution - if isinstance(execution, ControlledExecution): - await session.change(execution.toggle) - else: - session.view.feedback.flash(session.display.heading(execution)) + async def apply(cls, session: GoalSession) -> None: + await session.execution.apply_toggle(session.change, session.unavailable) class EditInteraction(GoalInteraction): label = "Edit" @classmethod - async def apply(cls, session): + async def apply(cls, session: GoalSession) -> None: from toad.screens.goal_edit import GoalEdit from toad.widgets.goal_text import goal_mention_candidates view = session.view @@ -129,10 +126,10 @@ class GoalSession: def __init__(self, view: Conversation): self.view = view - self._modal = None + self._modal: ReferenceType[ModalScreen[None]] | None = None @property - def modal(self): + def modal(self) -> ModalScreen[None] | None: return self._modal() if self._modal is not None else None @property @@ -143,13 +140,13 @@ def display(self): def execution(self) -> GoalExecution: return self.view.goal_execution - def owns(self, agent) -> bool: + def owns(self, agent: AgentConnection) -> bool: view = self.view if not view.is_attached: return False return view.goal_controls is self and view.agent_binding is agent - def present(self, modal) -> None: + def present(self, modal: ModalScreen[None]) -> None: self._modal = ref(modal) self.view.app.push_screen(modal) @@ -162,11 +159,14 @@ async def activate(self, action: type[GoalInteraction]) -> None: if not self.owns(agent): return if not action.enabled(self): - view.feedback.flash(view.goal_display.heading(view.goal_execution)) + self.unavailable() return with self.reporting_refusals(agent): await action.apply(self) + def unavailable(self) -> None: + self.view.feedback.flash(self.display.heading(self.execution)) + @contextmanager def reporting_refusals(self, agent: AgentConnection) -> Iterator[None]: """Buttons and slash controls report once, only to their original source.""" @@ -189,7 +189,7 @@ async def change(self, action: type[GoalAction], text: str = "") -> None: if self.owns(agent): view.prompt.focus() - async def write(self, agent, operation): + async def write[Result](self, agent: AgentConnection, operation: Awaitable[Result]) -> Result: """Every write settles by reading the owner, including rejected writes.""" try: return await operation diff --git a/src/toad/screens/goal_edit.py b/src/toad/screens/goal_edit.py index 6a0edf303..cc7e96c22 100644 --- a/src/toad/screens/goal_edit.py +++ b/src/toad/screens/goal_edit.py @@ -19,7 +19,7 @@ from toad.widgets.channel_prompt import DraftPrompt -class GoalEdit(CoreEventReceiver, DismissibleScreen[str | None]): +class GoalEdit(CoreEventReceiver, DismissibleScreen[None]): AUTO_FOCUS = "ChannelTextArea" DEFAULT_CSS = """ GoalEdit { align: center middle; background: $background 40%; } @@ -83,7 +83,7 @@ async def save(self, text: str) -> None: except Refused as error: self.query_one("#goal-edit-error", Static).update(str(error)) else: - self.dismiss(text.strip()) + self.dismiss() finally: self.saving = False save_button.disabled = False