From c9617a29db07e7fb3a3bb3c55b88ae7a002061dc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 17:06:26 -0400 Subject: [PATCH 1/3] Move native image data out of the ACP prompt decoder --- src/agent_comms/agent_backend.py | 2 +- src/agent_comms/backend.py | 2 +- src/agent_comms/image_input.py | 41 ++++++++++++++++++++++++++ src/agent_comms/image_inputs.py | 49 +++++--------------------------- src/agent_comms/input_drain.py | 2 +- src/agent_comms/owned_turn.py | 2 +- src/agent_comms/pi_commands.py | 2 +- src/agent_comms/queued_input.py | 2 +- src/agent_comms/turn_runner.py | 2 +- tests/test_image_inputs.py | 3 +- tests/test_prompt_response.py | 2 +- tests/test_turn_context_wire.py | 2 +- 12 files changed, 59 insertions(+), 52 deletions(-) create mode 100644 src/agent_comms/image_input.py diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 487b97b552..1dbf9a7e38 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: from .agent_events import AgentEvent - from .image_inputs import ImageInput + from .image_input import ImageInput from .launch_runtime import LaunchRuntime from .native_tool_call import NativeToolMode from .pi_events import DialogUiRequest, ExtensionUiChoice diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 32802753a5..54908791f3 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -48,7 +48,7 @@ from .coordinator import Coordination from .extension_ui import ExtensionUiSession from .field_codec import Id128Text, MalformedRecord, boundary_decoder -from .image_inputs import ImageInput +from .image_input import ImageInput from .native_attestation import AttestationError from .native_child import PiSessionChild from .native_package import VerifiedNativePackage diff --git a/src/agent_comms/image_input.py b/src/agent_comms/image_input.py new file mode 100644 index 0000000000..8152a7fff2 --- /dev/null +++ b/src/agent_comms/image_input.py @@ -0,0 +1,41 @@ +"""Bounded image data carried by native prompts and queued input.""" + +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass, field +from typing import Annotated, Literal + +from . import field_codec + +IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) +MAX_IMAGE_BYTES = 4 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class ImageInput: + data: str + mime_type: Annotated[str, field_codec.WireName("mimeType")] + type: Annotated[Literal["image"], field_codec.WireRequired()] = field( + default="image", kw_only=True + ) + + def __post_init__(self) -> None: + if self.mime_type not in IMAGE_MIME_TYPES: + raise ValueError(f"Unsupported image type: {self.mime_type}") + if len(self.data) > 4 * ((MAX_IMAGE_BYTES + 2) // 3): + raise ValueError("Image attachments exceed the 4 MiB prompt limit.") + try: + decoded = base64.b64decode(self.data, validate=True) + except (ValueError, binascii.Error) as error: + raise ValueError("Image data must be valid base64.") from error + if not decoded: + raise ValueError("Image data is empty.") + if len(decoded) > MAX_IMAGE_BYTES: + raise ValueError("Image attachments exceed the 4 MiB prompt limit.") + + @property + def size(self) -> int: + return len(self.data) * 3 // 4 - (len(self.data) - len(self.data.rstrip("="))) + diff --git a/src/agent_comms/image_inputs.py b/src/agent_comms/image_inputs.py index f31bd9cadd..f81c7ffd0d 100644 --- a/src/agent_comms/image_inputs.py +++ b/src/agent_comms/image_inputs.py @@ -2,11 +2,6 @@ from __future__ import annotations -import base64 -import binascii -from dataclasses import dataclass, field -from typing import Annotated, Literal - from acp.schema import ( BlobResourceContents, EmbeddedResourceContentBlock, @@ -15,58 +10,28 @@ TextResourceContents, ) -from agent_comms import field_codec - +from . import image_input from .mro_dispatch import MroDispatch, handles -IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) -MAX_IMAGE_BYTES = 4 * 1024 * 1024 MAX_PROMPT_IMAGES = 8 -@dataclass(frozen=True, slots=True) -class ImageInput: - data: str - mime_type: Annotated[str, field_codec.WireName("mimeType")] - type: Annotated[Literal["image"], field_codec.WireRequired()] = field( - default="image", kw_only=True - ) - - def __post_init__(self) -> None: - if self.mime_type not in IMAGE_MIME_TYPES: - raise ValueError(f"Unsupported image type: {self.mime_type}") - if len(self.data) > 4 * ((MAX_IMAGE_BYTES + 2) // 3): - raise ValueError("Image attachments exceed the 4 MiB prompt limit.") - try: - decoded = base64.b64decode(self.data, validate=True) - except (ValueError, binascii.Error) as error: - raise ValueError("Image data must be valid base64.") from error - if not decoded: - raise ValueError("Image data is empty.") - if len(decoded) > MAX_IMAGE_BYTES: - raise ValueError("Image attachments exceed the 4 MiB prompt limit.") - - @property - def size(self) -> int: - return len(self.data) * 3 // 4 - (len(self.data) - len(self.data.rstrip("="))) - - class PromptImages(MroDispatch): """Project ACP's declared content family into bounded native images.""" def __init__(self) -> None: - self.images: list[ImageInput] = [] + self.images: list[image_input.ImageInput] = [] - def append(self, image: ImageInput) -> None: + def append(self, image: image_input.ImageInput) -> None: self.images.append(image) if len(self.images) > MAX_PROMPT_IMAGES: raise ValueError("At most 8 image attachments are allowed per prompt.") - if sum(image.size for image in self.images) > MAX_IMAGE_BYTES: + if sum(image.size for image in self.images) > image_input.MAX_IMAGE_BYTES: raise ValueError("Image attachments exceed the 4 MiB prompt limit.") @handles(ImageContentBlock) def image(self, block: ImageContentBlock) -> None: - self.append(ImageInput(block.data, block.mime_type)) + self.append(image_input.ImageInput(block.data, block.mime_type)) @handles(EmbeddedResourceContentBlock) def resource(self, block: EmbeddedResourceContentBlock) -> None: @@ -75,7 +40,7 @@ def resource(self, block: EmbeddedResourceContentBlock) -> None: @handles(BlobResourceContents) def blob(self, resource: BlobResourceContents) -> None: if resource.mime_type and resource.mime_type.startswith("image/"): - self.append(ImageInput(resource.blob, resource.mime_type)) + self.append(image_input.ImageInput(resource.blob, resource.mime_type)) @handles(TextResourceContents) def text(self, resource: TextResourceContents) -> None: @@ -83,7 +48,7 @@ def text(self, resource: TextResourceContents) -> None: raise ValueError("Image input requires base64 data and a MIME type.") -def prompt_images(request: PromptRequest) -> tuple[ImageInput, ...]: +def prompt_images(request: PromptRequest) -> tuple[image_input.ImageInput, ...]: images = PromptImages() for block in request.prompt: images.dispatch_sync(block) diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index ca34ec87b3..62ca1e0a13 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -46,7 +46,7 @@ ) from .input_disposition import InputDispositions, InputDocument from .input_effects import InputEffects -from .image_inputs import ImageInput +from .image_input import ImageInput from .lifecycle_capability import Executing from .maintenance_barrier import MaintenanceBarrier, MaintenanceIngress from .native_input_owner import RegistryOwner diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index cbe06a4f99..90b9477904 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, NoReturn -from .image_inputs import ImageInput +from .image_input import ImageInput from acp import RequestError diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 7c80c87a61..03a26d4d10 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -24,7 +24,7 @@ WireName, WireOmitDefault, ) -from .image_inputs import ImageInput +from .image_input import ImageInput from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage from .native_session_reopen import NativeSessionIdentity diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 702cb61a88..45b33a82aa 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -27,7 +27,7 @@ from .errors import RelationViolationError from .field_codec import Id128Text from .goals import GoalDeclaration, NoGoal -from .image_inputs import ImageInput +from .image_input import ImageInput from .input_attempt import InputAttempt, ReservedInput from .input_disposition import InputDispositions from .input_origin import InputOrigin, UnattributedInputOrigin, WireInputOrigin diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index e2b146bc48..fe18f03a06 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -29,7 +29,7 @@ PausedGoalAction, ) from .goal_scheduler import GoalScheduler -from .image_inputs import ImageInput +from .image_input import ImageInput from .input_origin import InputOrigin, UnattributedInputOrigin from .messages import Message from .mro_dispatch import MroDispatch, handles diff --git a/tests/test_image_inputs.py b/tests/test_image_inputs.py index 4e079c1bae..8491d42d1f 100644 --- a/tests/test_image_inputs.py +++ b/tests/test_image_inputs.py @@ -13,7 +13,8 @@ ) from agent_comms.comms import wire -from agent_comms.image_inputs import MAX_IMAGE_BYTES, ImageInput, prompt_images +from agent_comms.image_input import MAX_IMAGE_BYTES, ImageInput +from agent_comms.image_inputs import prompt_images from agent_comms.native_arguments import GivenArguments, GivenBinary from bus_history import read_history from delivery_owner_fixture import canonical_agent diff --git a/tests/test_prompt_response.py b/tests/test_prompt_response.py index 846dcb6477..ed452edcf5 100644 --- a/tests/test_prompt_response.py +++ b/tests/test_prompt_response.py @@ -4,7 +4,7 @@ import pytest from agent_comms.field_codec import FieldCodec -from agent_comms.image_inputs import ImageInput +from agent_comms.image_input import ImageInput from agent_comms.native_tool_call import OwnerToolConnection from agent_comms.pi_commands import PiCommand, Prompt from agent_comms.pi_payloads import AnonymousRequest, RequestId diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index aa048078f2..b37394c3e1 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -7,7 +7,7 @@ from agent_comms.turn_context import ContextManifest, RecordedContextTurn, SegmentManifest, OwnerProvenance, TurnContext, TranscriptSegment, UserInputSegment from agent_comms.pi_commands import Prompt from agent_comms.native_tool_call import OwnerToolConnection -from agent_comms.image_inputs import ImageInput +from agent_comms.image_input import ImageInput from agent_comms.field_codec import FieldCodec from bus_record_fixture import context_comms From ff66279da4db8594031c76b134614729834c8f62 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 17:16:19 -0400 Subject: [PATCH 2/3] Carry tool action capabilities to the ACP adapter without SDK imports --- src/agent_comms/agent_event_updates.py | 56 ++++++++++++++++++--- src/agent_comms/agent_events.py | 16 +++--- src/agent_comms/backend.py | 15 +++--- src/agent_comms/image_input.py | 1 - src/agent_comms/input_drain.py | 6 +-- src/agent_comms/native_tools.py | 31 ++++++------ src/agent_comms/owned_turn.py | 24 ++++----- src/agent_comms/pi_commands.py | 4 +- src/agent_comms/queued_input.py | 5 +- src/agent_comms/tool_declaration.py | 69 +++++++++++++++++++++----- src/agent_comms/transcript_events.py | 7 ++- tests/test_durable_host_records.py | 14 +++--- tests/test_native_tool_declarations.py | 41 +++++++++------ tests/test_turn_context_wire.py | 20 +++++--- 14 files changed, 206 insertions(+), 103 deletions(-) diff --git a/src/agent_comms/agent_event_updates.py b/src/agent_comms/agent_event_updates.py index f19807f86a..7a4004f220 100644 --- a/src/agent_comms/agent_event_updates.py +++ b/src/agent_comms/agent_event_updates.py @@ -13,6 +13,7 @@ TextContentBlock, ToolCallProgress, ToolCallStart, + ToolKind, UsageUpdate, ) @@ -27,6 +28,14 @@ from .context_reading import ContextUsage, UnmeasuredContext from .coordinator import Coordination from .mro_dispatch import MroDispatch, handles +from .tool_declaration import ( + EditAction, + ExecuteAction, + OtherAction, + ReadAction, + SearchAction, + ToolAction, +) from .tool_results import tool_result_content from .transcript_updates import TranscriptUpdate @@ -36,6 +45,40 @@ from .turn_effects import TurnEffects +class AcpToolAction(MroDispatch): + """Translate the tool's capability only when building an ACP message.""" + + @handles(SearchAction) + @staticmethod + def search(action: type[SearchAction]) -> ToolKind: + return "search" + + @handles(ReadAction) + @staticmethod + def read(action: type[ReadAction]) -> ToolKind: + return "read" + + @handles(ExecuteAction) + @staticmethod + def execute(action: type[ExecuteAction]) -> ToolKind: + return "execute" + + @handles(EditAction) + @staticmethod + def edit(action: type[EditAction]) -> ToolKind: + return "edit" + + @handles(OtherAction) + @staticmethod + def other(action: type[OtherAction]) -> ToolKind: + return "other" + + @classmethod + def kind(cls, action: type[ToolAction]) -> ToolKind: + kind: ToolKind = cls.handler_for(action)(action) + return kind + + @dataclass(eq=False, repr=False) class AcpEventConsumer(MroDispatch): agent: TurnEffects @@ -48,7 +91,8 @@ async def transcript(self, event: TranscriptUpdate) -> None: def text_update(self, text: str) -> AgentMessageChunk: return AgentMessageChunk( - session_update="agent_message_chunk", content=TextContentBlock(type="text", text=text), + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=text), ) @handles(events.GoalChanged) @@ -70,7 +114,7 @@ async def on_tool_start(self, event: events.ToolStart) -> None: session_update="tool_call", tool_call_id=event.id, title=event.title or event.name or "tool", - kind=event.kind, + kind=AcpToolAction.kind(event.kind), status="in_progress", ) if event.args is not None: @@ -109,7 +153,9 @@ async def on_tool_end(self, event: events.ToolEnd) -> None: status="completed" if event.ok else "failed", content=[ ContentToolCallContent.model_validate(item) - for item in tool_result_content(event.id, event.output or "", event.diff, event.sent_message) + for item in tool_result_content( + event.id, event.output or "", event.diff, event.sent_message + ) ], ) await client.publish_update(session_id=session_id, update=end_update) @@ -142,9 +188,7 @@ async def on_unmeasured_context(self, _reading: UnmeasuredContext) -> None: await self.agent.sessions.publish_metadata(self.session_id) @handles(events.CompactionEvent) - async def on_compaction( - self, event: events.CompactionEvent - ) -> None: + async def on_compaction(self, event: events.CompactionEvent) -> None: await self.agent.turns.observe_compaction(self.session_id, event) await self.client.publish_update( session_id=self.session_id, diff --git a/src/agent_comms/agent_events.py b/src/agent_comms/agent_events.py index 7260c23ddf..db6f6f2722 100644 --- a/src/agent_comms/agent_events.py +++ b/src/agent_comms/agent_events.py @@ -11,8 +11,6 @@ from functools import partial from typing import TYPE_CHECKING, ClassVar -from acp.schema import ToolKind - from .acp_failure import ( FailedNativeTurnOutcome, FailureSource, @@ -22,9 +20,6 @@ UnrecordedFailureSource, UnresolvedNativeTurnOutcome, ) -from .turn_terminal import FailedGoalTurn -from .turn_goal_account import OriginGoalSettlement, VerifiedGoalSettlement -from .turn_failure import BackendFailed, TurnFailure from .child_process import ProcessIdentity from .declared_family import DeclaredFamily from .field_codec import NullMember @@ -32,7 +27,6 @@ from .mro_dispatch import MroDispatch, handles from .native_failures import NativePiUnavailable from .pi_counts import PiCount, UnreportedCount -from .thread_model import ThreadModel, UndeclaredModel from .pi_payloads import McpLiveAcknowledgement, PiDiagnostic, PiRequestId, PiUsage from .pi_vocabulary import ( CompactionReason, @@ -40,8 +34,12 @@ ThinkingLevelField, UnknownCompactionReason, ) -from .tool_declaration import ToolDeclaration +from .thread_model import ThreadModel, UndeclaredModel +from .tool_declaration import OtherAction, ToolAction, ToolDeclaration from .tool_results import NoSentMessage, NoToolDiff, ToolDiff +from .turn_failure import BackendFailed, TurnFailure +from .turn_goal_account import OriginGoalSettlement, VerifiedGoalSettlement +from .turn_terminal import FailedGoalTurn if TYPE_CHECKING: from .agent_event_updates import AcpEventConsumer @@ -203,13 +201,13 @@ class ToolEvent(AgentEvent): class ToolStart(ToolEvent): title: str = "" args: object = None - kind: ToolKind = "other" + kind: type[ToolAction] = OtherAction @classmethod def from_call(cls, call_id: str, name: str, arguments: object) -> ToolStart: declaration = ToolDeclaration.for_name(name) title = ToolDeclaration.caption(name, ToolDeclaration.record(name, arguments, call_id=call_id)) - return cls(call_id, name, title, arguments, declaration.acp_kind) + return cls(call_id, name, title, arguments, declaration.action()) @dataclass(frozen=True) diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 54908791f3..bf9e040779 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -15,8 +15,6 @@ from __future__ import annotations -from agent_comms.participant_identity import ParticipantIdentity - import asyncio import json import secrets @@ -37,33 +35,34 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar +from agent_comms.participant_identity import ParticipantIdentity from agent_comms.pi_payloads import RequestId from . import agent_events as events from . import pi_commands as commands from . import pi_events as pi from . import turn_failure as failures -from .agent_backend import TurnInbox from .acp_failure import UnrecordedFailureSource +from .agent_backend import TurnInbox from .coordinator import Coordination from .extension_ui import ExtensionUiSession from .field_codec import Id128Text, MalformedRecord, boundary_decoder from .image_input import ImageInput +from .mro_dispatch import MroDispatch, handles from .native_attestation import AttestationError from .native_child import PiSessionChild +from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage from .native_pi import NativePiRpcLaunch -from .native_failures import NativePiUnavailable from .native_session_reopen import SessionRetention from .native_startup import NATIVE_STARTUP_POLICY, NativeStartup +from .native_tool_call import NativeToolMode +from .native_tool_transport import ToolTransport, UnboundToolTransport from .pi_rpc import PiRpcChannel from .prompt_state import UnwrittenPrompt from .request_progress import NO_REQUEST_OBSERVER, RequestObserver, RequestProgress -from .native_tool_call import NativeToolMode -from .native_tool_transport import ToolTransport, UnboundToolTransport -from .mro_dispatch import MroDispatch, handles -from .tool_denial import SelectedToolDenied from .send_outcome import SendBoundary +from .tool_denial import SelectedToolDenied from .turn_context import InputContributionCoordinates from .turn_inputs import InputForwarding from .turn_output import TurnOutput diff --git a/src/agent_comms/image_input.py b/src/agent_comms/image_input.py index 8152a7fff2..be7215df55 100644 --- a/src/agent_comms/image_input.py +++ b/src/agent_comms/image_input.py @@ -38,4 +38,3 @@ def __post_init__(self) -> None: @property def size(self) -> int: return len(self.data) * 3 // 4 - (len(self.data) - len(self.data.rstrip("="))) - diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index 62ca1e0a13..7eb130e5d9 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -21,15 +21,15 @@ encode_updates, ) from .activity import FailedWakeDiagnostic -from .cohort_schema import AcceptanceFrontier from .agent_backend import ClearQueue, InputContent, InputId, SendNow, TurnInbox, TurnInboxItem from .bus import Bus from .child_process import join_retirement +from .cohort_schema import AcceptanceFrontier from .comms import Comms +from .controller import UNBOUND_CONTROLLER, SessionUpdateSink from .coordination_cohort import next_sealed_assignment from .coordination_database import CoordinationStore from .coordinator import Coordination -from .controller import UNBOUND_CONTROLLER, SessionUpdateSink from .cursor_owner import CursorOwner from .drain_failures import ( DEFERRED_DRAIN_ERRORS, @@ -38,6 +38,7 @@ record_drain_failure, ) from .errors import RelationViolationError, UnregisteredThreadError +from .image_input import ImageInput from .input_attempt import ( UNOBSERVED_DELIVERY_SCOPE, InputAttempt, @@ -46,7 +47,6 @@ ) from .input_disposition import InputDispositions, InputDocument from .input_effects import InputEffects -from .image_input import ImageInput from .lifecycle_capability import Executing from .maintenance_barrier import MaintenanceBarrier, MaintenanceIngress from .native_input_owner import RegistryOwner diff --git a/src/agent_comms/native_tools.py b/src/agent_comms/native_tools.py index ca9ad19cee..0252fcfbe6 100644 --- a/src/agent_comms/native_tools.py +++ b/src/agent_comms/native_tools.py @@ -6,16 +6,24 @@ from abc import abstractmethod from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Annotated, Literal - +from typing import TYPE_CHECKING, Annotated, ClassVar, Literal from .envelope_claim_transitions import ExistingFileClaim, FileClaimPath, WritableFileClaim from .field_codec import FieldCodec, NullMember, WireContext, WireOmitDefault from .native_tool_call import CodingCall -from .tool_denial import SelectedToolDenied from .recorded_time import EventTime from .storage_rules import WriteStorageEffect, physical_path, shell_storage_effects -from .tool_declaration import EditCall, ReplacementEdit, ToolDeclaration, one_line +from .tool_declaration import ( + EditAction, + EditCall, + ExecuteAction, + ReadAction, + ReplacementEdit, + SearchAction, + ToolDeclaration, + one_line, +) +from .tool_denial import SelectedToolDenied if TYPE_CHECKING: from .pi_payloads import PiToolResult @@ -122,8 +130,7 @@ def reads(self) -> str: @dataclass(frozen=True, kw_only=True) -class PatternSearchTool: - acp_kind = "search" +class PatternSearchTool(SearchAction): pattern: str path: Annotated[str | UnsuppliedArgument, WireOmitDefault()] = UnsuppliedArgument() @@ -152,8 +159,7 @@ def native_extension(cls, package: Path) -> Path: @dataclass(frozen=True, kw_only=True) -class ReadTool(PathTool, CodingTool): - acp_kind = "read" +class ReadTool(ReadAction, PathTool, CodingTool): label = "Read" offset: Annotated[float | UnsuppliedArgument, WireOmitDefault()] = UnsuppliedArgument() limit: Annotated[float | UnsuppliedArgument, WireOmitDefault()] = UnsuppliedArgument() @@ -163,8 +169,7 @@ def _parse_resource(self) -> None: @dataclass(frozen=True, kw_only=True) -class BashTool(CodingTool): - acp_kind = "execute" +class BashTool(ExecuteAction, CodingTool): label = "Run" command: str timeout: Annotated[float | UnsuppliedArgument, WireOmitDefault()] = UnsuppliedArgument() @@ -179,8 +184,7 @@ def storage_effects(self, cwd): return shell_storage_effects(self.command, cwd) -class MutatingPathTool(PathTool): - acp_kind = "edit" +class MutatingPathTool(EditAction, PathTool): claim_type: ClassVar[type[FileClaimPath]] def _parse_resource(self): @@ -233,9 +237,8 @@ def replacements(self) -> tuple[ReplacementEdit, ...]: @dataclass(frozen=True, kw_only=True) -class FilePatchTool(PathTool, ToolDeclaration): +class FilePatchTool(EditAction, PathTool, ToolDeclaration): """A file change known by its path and diff alone (Codex's patch), without replacement text.""" - acp_kind = "edit" label = "Edit" diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 90b9477904..deafa7ad68 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -9,31 +9,31 @@ from pathlib import Path from typing import TYPE_CHECKING, NoReturn -from .image_input import ImageInput - from acp import RequestError from agent_comms.following_inputs import OwnerFollowingInputs +from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit from . import agent_events as events from .agent_backend import InputContent, TurnDelivery from .channel_input_batch import InputBatch +from .controller import UNBOUND_CONTROLLER +from .coordinator import Coordination from .field_codec import Id128Text -from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit from .goals import Goal -from .messages import Message +from .image_input import ImageInput from .input_attempt import ReservedInput from .input_origin import WireInputOrigin -from .owned_send import OwnedSend -from .native_input_owner import RegistryOwner +from .messages import Message from .native_failures import NativeRequestFailure -from .coordinator import Coordination +from .native_input_owner import RegistryOwner +from .owned_send import OwnedSend from .routing import MessageRoute, ScheduledTurn, TurnRouting -from .controller import UNBOUND_CONTROLLER from .thread_identity import TurnId from .turn_goal_account import TurnGoalAccount from .turn_goal_permission import ( - OwnerGoalPermission, TurnGoalPermission, + OwnerGoalPermission, + TurnGoalPermission, ) from .turn_input_source import ( CapturedInputDependency, @@ -43,9 +43,9 @@ RoutedOriginalInput, ScheduledOriginalInput, ) +from .turn_phase import CancellingPhase, PromptAcceptancePhase from .turn_progress import TurnProgress from .turn_terminal import EndedWithoutResult, TurnTerminal -from .turn_phase import CancellingPhase, PromptAcceptancePhase if TYPE_CHECKING: from .turn_runner import TurnRunner @@ -211,11 +211,11 @@ def prepare_prompt(self, batch: InputBatch): self.runner.comms.root, self.runner.comms.registry.snapshot(), self.worktree ) from .turn_context import ( - TurnContext, AutomaticTitleSegment, - ReplyRouteSegment, OwnerProvenance, RecordedContextTurn, + ReplyRouteSegment, + TurnContext, ) self.relations = self.runner.comms.relationships.context_relations(self.thread.name) diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 03a26d4d10..d946a2fd0c 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -13,9 +13,9 @@ from pydantic import JsonValue -from .agent_backend import StreamingInputPolicy from . import agent_events as events from . import turn_phase as phases +from .agent_backend import StreamingInputPolicy from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import ( FieldCodec, @@ -259,9 +259,9 @@ async def catalog( ): """Acquire metadata commands without borrowing a saved or executing session.""" from .child_process import BoundedRun + from .native_child import PiChild from .native_pi import NativePiRpcLaunch from .pi_rpc import PiRpcChannel - from .native_child import PiChild try: launch = await asyncio.to_thread( diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 45b33a82aa..ddf48e0d1c 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -15,15 +15,14 @@ from acp import RequestError - from .assignment_states import DeferredAssignment, DeliveredAssignment from .child_process import ProcessIdentity +from .controller import Controller from .coordination_cohort import pending_sealed_assignments from .coordination_database import CoordinationStore from .coordination_errors import IdentityConflict, StaleRevision from .coordination_session import ReadNativeCoordinationSession from .coordinator import Coordination -from .controller import Controller from .errors import RelationViolationError from .field_codec import Id128Text from .goals import GoalDeclaration, NoGoal @@ -31,7 +30,7 @@ from .input_attempt import InputAttempt, ReservedInput from .input_disposition import InputDispositions from .input_origin import InputOrigin, UnattributedInputOrigin, WireInputOrigin -from .thread_identity import ClaimScoped, ClaimedIdentity +from .thread_identity import ClaimedIdentity, ClaimScoped from .threads import Thread from .turn_goal_permission import AcceptedGoalPermission, TurnGoalPermission from .turn_input_source import AcceptedFollowingInput, DeliveredFollowingInput diff --git a/src/agent_comms/tool_declaration.py b/src/agent_comms/tool_declaration.py index 797ef565e2..610bc294fb 100644 --- a/src/agent_comms/tool_declaration.py +++ b/src/agent_comms/tool_declaration.py @@ -3,12 +3,11 @@ from __future__ import annotations import json +from abc import ABC from collections.abc import Mapping from dataclasses import dataclass from typing import Annotated, ClassVar, Self -from acp.schema import ToolKind - from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import FieldCodec, MalformedRecord, NullMember, WireName from .recorded_time import EventTime @@ -17,7 +16,7 @@ def one_line(text: str, limit: int) -> str: - return ' '.join(text.split())[:limit] + return " ".join(text.split())[:limit] @dataclass(frozen=True) @@ -36,11 +35,49 @@ class EditCall: problem: str +class ToolAction(ABC): + """A tool capability, composed beside its declaration family.""" + + members: ClassVar[set[type[ToolAction]]] = set() + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + if ToolAction in cls.__bases__: + ToolAction.members.add(cls) + + +class SearchAction(ToolAction): + """Find matching information.""" + + +class ReadAction(ToolAction): + """Read existing content.""" + + +class ExecuteAction(ToolAction): + """Execute a command.""" + + +class EditAction(ToolAction): + """Change content.""" + + +class OtherAction(ToolAction): + """A tool with no more specific action.""" + + class ToolDeclaration(DeclaredFamily, affix="Tool"): family_discriminator: ClassVar[str] = "tool" - acp_kind: ClassVar[ToolKind] = "other" label: ClassVar[str] + @classmethod + def action(cls) -> type[ToolAction]: + """The first action capability in the tool's C3 MRO.""" + return next( + (action for base in cls.__mro__ for action in ToolAction.members if base is action), + OtherAction, + ) + @classmethod def _member_declarations(cls): # These existing owners share this registry; imports acquire declarations. @@ -63,28 +100,32 @@ def parse_arguments(cls, arguments: Mapping[str, object], *, call_id: str) -> Se return FieldCodec.decode_bound(cls, {**selected, **arguments}, call_id=call_id) @classmethod - def record(cls, name: str, arguments: object, *, call_id: str) -> ToolDeclaration | NoToolRecord: + def record( + cls, name: str, arguments: object, *, call_id: str + ) -> ToolDeclaration | NoToolRecord: """The call decoded once as Core's tool record, or no record when Core does not decode it.""" try: decoded = FieldCodec.decode(dict[str, object], arguments) return cls.for_name(name).parse_arguments(decoded, call_id=call_id) - except (MalformedRecord, SelectedToolDenied): + except MalformedRecord, SelectedToolDenied: return NoToolRecord() def recorded_call(self, shown): """The shown call, carrying this record.""" return shown.recorded(self) - def edit_calls(self, call_id: str, at: EventTime, thread: str, problem: str) -> tuple[EditCall, ...]: + def edit_calls( + self, call_id: str, at: EventTime, thread: str, problem: str + ) -> tuple[EditCall, ...]: return () def reads(self) -> str: """What the call names after its label: nothing unless the tool says.""" - return '' + return "" def digest(self) -> str: """What the call does in one line, enough to judge it.""" - return f'{self.declared_name} {one_line(self.reads(), 260)}' + return f"{self.declared_name} {one_line(self.reads(), 260)}" def call_digest(self, name: str, arguments: object) -> str: """A call in one line: its record's digest.""" @@ -94,7 +135,7 @@ def call_digest(self, name: str, arguments: object) -> str: def caption(cls, name: str, tool: ToolDeclaration | NoToolRecord) -> str: """A call reads as its label, then what its record names; with no record, its label.""" reads = one_line(text := tool.reads(), len(text)) - return cls.for_name(name).label + (' ' + reads if reads else '') + return cls.for_name(name).label + (" " + reads if reads else "") @classmethod def result_diff(cls, result, ok): @@ -107,13 +148,15 @@ class NoToolRecord(NullMember): """Core did not decode the call into a tool record; its raw arguments stand.""" def reads(self) -> str: - return '' + return "" - def edit_calls(self, call_id: str, at: EventTime, thread: str, problem: str) -> tuple[EditCall, ...]: + def edit_calls( + self, call_id: str, at: EventTime, thread: str, problem: str + ) -> tuple[EditCall, ...]: return () def call_digest(self, name: str, arguments: object) -> str: - return f'{name} {one_line(json.dumps(arguments), 260)}' + return f"{name} {one_line(json.dumps(arguments), 260)}" def recorded_call(self, shown): return shown diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index 41c78dbe27..09fabe4cee 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -6,12 +6,11 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, ClassVar, Annotated +from typing import TYPE_CHECKING, Annotated, ClassVar from urllib.parse import quote from pydantic import JsonValue - from . import tool_declaration from .declared_family import DeclaredFamily from .field_codec import FieldCodec, NullMember, PresentMember, WireBind, WireOmitDefault @@ -420,8 +419,8 @@ def title(self) -> str: return tool_declaration.ToolDeclaration.caption(self.tool_name, tool_declaration.NoToolRecord()) @property - def kind(self) -> str: - return tool_declaration.ToolDeclaration.for_name(self.tool_name).acp_kind + def kind(self) -> type[tool_declaration.ToolAction]: + return tool_declaration.ToolDeclaration.for_name(self.tool_name).action() @dataclass(frozen=True, kw_only=True) diff --git a/tests/test_durable_host_records.py b/tests/test_durable_host_records.py index 942ab1f8a4..7429425a6a 100644 --- a/tests/test_durable_host_records.py +++ b/tests/test_durable_host_records.py @@ -3,11 +3,6 @@ import json import pytest -from agent_comms.session_file import RegistrySessionLocation -from agent_comms.pi_payloads import ReportedText, RequestId -from agent_comms.field_codec import FieldCodec, MalformedRecord -from agent_comms.native_entries import NativeEntry, StoredEntry, TranscriptProjection -from agent_comms.recorded_time import UnrecordedTimeError from agent_comms.agent_backend import ( FailedCompaction, @@ -16,6 +11,8 @@ StaleCompaction, ) from agent_comms.agent_events import Chunk, Error, Thinking, ToolStart +from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.native_entries import NativeEntry, StoredEntry, TranscriptProjection from agent_comms.pi_commands import GetSessionStats from agent_comms.pi_counts import PiCount, UnreportedCount from agent_comms.pi_durable_backend import ( @@ -27,9 +24,12 @@ Settle, ) from agent_comms.pi_events import PiEvent, UnknownPiEvent -from agent_comms.pi_payloads import PiContextUsage, ReportedContextUsage +from agent_comms.pi_payloads import PiContextUsage, ReportedContextUsage, ReportedText, RequestId from agent_comms.pi_rpc import PiRpcChannel +from agent_comms.recorded_time import UnrecordedTimeError +from agent_comms.session_file import RegistrySessionLocation from agent_comms.threads import Thread +from agent_comms.tool_declaration import ReadAction def decode(wire): @@ -57,7 +57,7 @@ def test_compaction_reply_decodes_directly_to_existing_outcome(result, expected) {'type': 'thinking_delta', 'contentIndex': 1, 'delta': 'reasoning'}}, (Thinking('reasoning'),)), ({'type': 'tool_execution_start', 'toolCallId': 'call', 'toolName': 'read', 'args': {'path': 'source.py'}}, - (ToolStart(id='call', name='read', title='Read source.py', kind='read', args={'path': 'source.py'}),)), + (ToolStart(id='call', name='read', title='Read source.py', kind=ReadAction, args={'path': 'source.py'}),)), ({'type': 'task_failed', 'taskId': 3, 'kind': 'tool', 'message': 'failed'}, (Error('failed'),)), ]) def test_event_fields_and_changes_are_decoded_at_frame_ingress(event, expected): diff --git a/tests/test_native_tool_declarations.py b/tests/test_native_tool_declarations.py index 990c98237c..147fc9557a 100644 --- a/tests/test_native_tool_declarations.py +++ b/tests/test_native_tool_declarations.py @@ -1,29 +1,36 @@ """One tool declaration controls presentation without granting coding authority.""" -from conftest import transcript_records -from dataclasses import dataclass import asyncio import json +from dataclasses import dataclass import acp import pytest from acp.schema import ToolCallStart import agent_comms.agent_events as events +from agent_comms.acp import SdkSessionConnection from agent_comms.agent_event_updates import AcpEventConsumer from agent_comms.comms import wire -from agent_comms.acp import SdkSessionConnection from agent_comms.native_tools import BashTool, CodingTool, EditTool, ReadTool, WriteTool -from agent_comms.tool_declaration import ReplacementEdit -from agent_comms.tool_declaration import ToolDeclaration, UndeclaredTool +from agent_comms.tool_declaration import ( + EditAction, + ExecuteAction, + OtherAction, + ReadAction, + ReplacementEdit, + SearchAction, + ToolDeclaration, + UndeclaredTool, +) +from conftest import transcript_records from delivery_owner_fixture import canonical_agent async def test_new_tool_declaration_drives_presentation_without_coding_permission(acp_tools): @dataclass(frozen=True, kw_only=True) - class InspectFixtureTool(ToolDeclaration): - acp_kind = "search" + class InspectFixtureTool(SearchAction, ToolDeclaration): label = "Inspect" query: str @@ -31,7 +38,7 @@ def reads(self): return self.query event = events.ToolStart.from_call("case", "inspect_fixture", {"query": "new declaration"}) - assert (event.title, event.kind) == ("Inspect new declaration", "search") + assert (event.title, event.kind) == ("Inspect new declaration", SearchAction) owner, received = acp_tools await AcpEventConsumer(owner, "declared-tool", owner.sessions.client).dispatch(event) async with asyncio.timeout(3): @@ -52,12 +59,12 @@ def reads(self): call = type(tool).from_call(tool.declared_name, tool.arguments, call_id="case") assert type(call) is type(tool) event = events.ToolStart.from_call("case", tool.declared_name, call.arguments) - assert event.kind == tool.acp_kind and event.args == tool.arguments + assert event.kind is tool.action() and event.args == tool.arguments assert call.call_id == "case" and call.arguments == tool.arguments and tool.reads() in event.title unknown = events.ToolStart.from_call("unknown", "external_extension", {"private": "preserved"}) assert (unknown.title, unknown.kind, unknown.args) == ( "external_extension", - "other", + OtherAction, {"private": "preserved"}, ) @@ -69,7 +76,7 @@ async def test_pattern_search_metadata_reaches_official_acp(acp_tools, name, lab arguments = {"pattern": "shared preview"} event = events.ToolStart.from_call("pattern-case", name, arguments) recorded = transcript_records.event(ToolStartTranscript, position=0, tool_call_id="pattern-case", tool_name=name, raw_input=arguments, tool=ToolDeclaration.record(name, arguments, call_id="pattern-case")) - assert (event.title, event.kind) == (f"{label} shared preview", "search") + assert (event.title, event.kind) == (f"{label} shared preview", SearchAction) assert (recorded.title, recorded.kind) == (event.title, event.kind) owner, received = acp_tools await AcpEventConsumer(owner, "pattern-tool", owner.sessions.client).dispatch(event) @@ -84,8 +91,8 @@ async def test_pattern_search_metadata_reaches_official_acp(acp_tools, name, lab @pytest.mark.parametrize("name,label,kind", [ - ("read", "Read", "read"), ("bash", "Run", "execute"), - ("edit", "Edit", "edit"), ("write", "Write", "edit"), + ("read", "Read", ReadAction), ("bash", "Run", ExecuteAction), + ("edit", "Edit", EditAction), ("write", "Write", EditAction), ]) def test_recorded_metadata_does_not_require_an_executable_request(name, label, kind): from agent_comms.native_tool_call import CodingCall @@ -110,9 +117,9 @@ def test_recorded_metadata_does_not_require_an_executable_request(name, label, k def test_foreign_protocol_owners_decode_canonical_preview_arguments(): from agent_comms.cli_bridges.codex_protocol import FileChange, UpdatedFile from agent_comms.cli_bridges.opencode_parts import ToolPart - from agent_comms.tool_calls import ToolCall from agent_comms.field_codec import FieldCodec from agent_comms.native_tools import FilePatchTool + from agent_comms.tool_calls import ToolCall from agent_comms.transcript_events import TranscriptEvent start, end = FileChange(path="codex.txt", diff="@@ -1 +1 @@\\n-old\\n+new\\n", @@ -135,7 +142,11 @@ def test_foreign_protocol_owners_decode_canonical_preview_arguments(): def test_core_commands_share_native_identity_without_changing_external_decode(): from agent_comms.native_tool_call import CodingCall - from agent_comms.native_tools import NativeToolDeclaration, NativeExecutableTool, NativeRequestTools + from agent_comms.native_tools import ( + NativeExecutableTool, + NativeRequestTools, + NativeToolDeclaration, + ) from agent_comms.tools import CommsSendTool, ToolRequest schemas = NativeRequestTools.catalog() diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index b37394c3e1..fe2e26db3a 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -1,15 +1,23 @@ """Original sealed messaging survives silent context rows through every projection.""" -from conftest import publish_initial import hashlib import json -from agent_comms.thread_identity import TurnId, TurnIdentity -from agent_comms.turn_context import ContextManifest, RecordedContextTurn, SegmentManifest, OwnerProvenance, TurnContext, TranscriptSegment, UserInputSegment -from agent_comms.pi_commands import Prompt -from agent_comms.native_tool_call import OwnerToolConnection -from agent_comms.image_input import ImageInput from agent_comms.field_codec import FieldCodec +from agent_comms.image_input import ImageInput +from agent_comms.native_tool_call import OwnerToolConnection +from agent_comms.pi_commands import Prompt +from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.turn_context import ( + ContextManifest, + OwnerProvenance, + RecordedContextTurn, + SegmentManifest, + TranscriptSegment, + TurnContext, + UserInputSegment, +) from bus_record_fixture import context_comms +from conftest import publish_initial def manifest(owner, generation=1): From 10b3ebbfe0e8b8ae8c7222ccd9722a437a187053 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 17:34:47 -0400 Subject: [PATCH 3/3] Avoid the MCP action field when naming the tool capability method --- src/agent_comms/agent_events.py | 2 +- src/agent_comms/tool_declaration.py | 2 +- src/agent_comms/transcript_events.py | 2 +- tests/test_native_tool_declarations.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/agent_events.py b/src/agent_comms/agent_events.py index db6f6f2722..5d597875d8 100644 --- a/src/agent_comms/agent_events.py +++ b/src/agent_comms/agent_events.py @@ -207,7 +207,7 @@ class ToolStart(ToolEvent): def from_call(cls, call_id: str, name: str, arguments: object) -> ToolStart: declaration = ToolDeclaration.for_name(name) title = ToolDeclaration.caption(name, ToolDeclaration.record(name, arguments, call_id=call_id)) - return cls(call_id, name, title, arguments, declaration.action()) + return cls(call_id, name, title, arguments, declaration.declared_action()) @dataclass(frozen=True) diff --git a/src/agent_comms/tool_declaration.py b/src/agent_comms/tool_declaration.py index 610bc294fb..685e9a5497 100644 --- a/src/agent_comms/tool_declaration.py +++ b/src/agent_comms/tool_declaration.py @@ -71,7 +71,7 @@ class ToolDeclaration(DeclaredFamily, affix="Tool"): label: ClassVar[str] @classmethod - def action(cls) -> type[ToolAction]: + def declared_action(cls) -> type[ToolAction]: """The first action capability in the tool's C3 MRO.""" return next( (action for base in cls.__mro__ for action in ToolAction.members if base is action), diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index 09fabe4cee..c4a39fe045 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -420,7 +420,7 @@ def title(self) -> str: @property def kind(self) -> type[tool_declaration.ToolAction]: - return tool_declaration.ToolDeclaration.for_name(self.tool_name).action() + return tool_declaration.ToolDeclaration.for_name(self.tool_name).declared_action() @dataclass(frozen=True, kw_only=True) diff --git a/tests/test_native_tool_declarations.py b/tests/test_native_tool_declarations.py index 147fc9557a..9779044810 100644 --- a/tests/test_native_tool_declarations.py +++ b/tests/test_native_tool_declarations.py @@ -59,7 +59,7 @@ def reads(self): call = type(tool).from_call(tool.declared_name, tool.arguments, call_id="case") assert type(call) is type(tool) event = events.ToolStart.from_call("case", tool.declared_name, call.arguments) - assert event.kind is tool.action() and event.args == tool.arguments + assert event.kind is tool.declared_action() and event.args == tool.arguments assert call.call_id == "case" and call.arguments == tool.arguments and tool.reads() in event.title unknown = events.ToolStart.from_call("unknown", "external_extension", {"private": "preserved"}) assert (unknown.title, unknown.kind, unknown.args) == (