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
2 changes: 1 addition & 1 deletion src/agent_comms/agent_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 50 additions & 6 deletions src/agent_comms/agent_event_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
TextContentBlock,
ToolCallProgress,
ToolCallStart,
ToolKind,
UsageUpdate,
)

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions src/agent_comms/agent_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,26 +20,26 @@
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
from .input_attempt import InputAttempt
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,
NoThinkingOverride,
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
Expand Down Expand Up @@ -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.declared_action())


@dataclass(frozen=True)
Expand Down
17 changes: 8 additions & 9 deletions src/agent_comms/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@

from __future__ import annotations

from agent_comms.participant_identity import ParticipantIdentity

import asyncio
import json
import secrets
Expand All @@ -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_inputs import ImageInput
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
Expand Down
40 changes: 40 additions & 0 deletions src/agent_comms/image_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""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("=")))
49 changes: 7 additions & 42 deletions src/agent_comms/image_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -75,15 +40,15 @@ 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:
if resource.mime_type and resource.mime_type.startswith("image/"):
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)
Expand Down
6 changes: 3 additions & 3 deletions src/agent_comms/input_drain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -46,7 +47,6 @@
)
from .input_disposition import InputDispositions, InputDocument
from .input_effects import InputEffects
from .image_inputs import ImageInput
from .lifecycle_capability import Executing
from .maintenance_barrier import MaintenanceBarrier, MaintenanceIngress
from .native_input_owner import RegistryOwner
Expand Down
Loading
Loading