diff --git a/src/agent_comms/cli_commands.py b/src/agent_comms/cli_commands.py index 3746e33285..1f38224f21 100644 --- a/src/agent_comms/cli_commands.py +++ b/src/agent_comms/cli_commands.py @@ -455,7 +455,7 @@ def apply_together(cls, ctx: Comms, commands: tuple[Self, ...]) -> tuple[CliResu @dataclass(frozen=True) -class TargetEdit(Command): +class TargetEdit(Command[Comms, CliResult]): """Accepted editor values use the declared field and current bound command.""" declaration: type[CliCommand] target: TargetSelection @@ -746,7 +746,7 @@ class NativeContextInspection(CliResult): native_source_spans: tuple[NativeSourceSpans, ...] -class CliExecution(Command): +class CliExecution[Answer: CliResult = CliResult](Command[Comms, Answer]): """The dispatcher contract, including methods selected through multiple inheritance.""" @abstractmethod @@ -801,7 +801,7 @@ class WorktreeArgument: class CliCommand[Answer: CliResult = CliResult]( DeclaredFamily, DeclaredOptions, - CliExecution, + CliExecution[Answer], affix="CliCommand", discovery_package="agent_comms.tools", ): diff --git a/src/agent_comms/cli_native_package.py b/src/agent_comms/cli_native_package.py index 3501da3ab3..9ef34eb18c 100644 --- a/src/agent_comms/cli_native_package.py +++ b/src/agent_comms/cli_native_package.py @@ -23,7 +23,7 @@ @dataclass(frozen=True, kw_only=True) -class NativePackageVerb(DeclaredVerb, Command, affix="Verb"): +class NativePackageVerb(DeclaredVerb, Command[None, None], affix="Verb"): """One verb: its fields declare its parser, and apply() does the work.""" @abstractmethod diff --git a/src/agent_comms/command.py b/src/agent_comms/command.py index 4b28458722..445f6040ed 100644 --- a/src/agent_comms/command.py +++ b/src/agent_comms/command.py @@ -1,18 +1,18 @@ """Inbound command contract; domain roots reuse DeclaredFamily and FieldCodec.""" from abc import ABC, abstractmethod -from typing import Any, Self +from typing import Self from pydantic import JsonValue from .field_codec import FieldCodec -class Command(ABC): +class Command[Context, Result](ABC): @classmethod def from_payload(cls, payload: dict[str, JsonValue]) -> Self: return FieldCodec.decode(cls, payload) @abstractmethod - def apply(self, ctx: Any) -> Any: + def apply(self, ctx: Context) -> Result: """Apply the command using its domain's shared preconditions.""" diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 199b20792a..a7d3d0354a 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -833,7 +833,7 @@ async def mcp(self, request: web.Request) -> web.Response: @dataclass(frozen=True, kw_only=True) class ConnectorVerb( - DeclaredVerb, Command, + DeclaredVerb, Command[ConnectorStore, int | str], affix="Verb", discovery_package=HostService.__module__, ): """One connector command: its options are its fields, and its parser comes from them.""" diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 38c3bb83eb..0eebe859cf 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -113,7 +113,9 @@ def model_report(self) -> bool: @dataclass(frozen=True, kw_only=True) -class GoalAction[Result: GoalDeclaration](DeclaredFamily, Command, affix="GoalAction"): +class GoalAction[Result: GoalDeclaration]( + DeclaredFamily, Command[GoalActionContext, Result], affix="GoalAction" +): expect: GoalPrecondition = GoalPrecondition() preserve_wait: ClassVar[bool] = False schedules_goal: ClassVar[bool] = False diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index cba36e0582..702cb61a88 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -11,7 +11,7 @@ from contextlib import AsyncExitStack, ExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import partial -from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar +from typing import TYPE_CHECKING, Generic, Self, TypeVar from acp import RequestError diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 85f38e90f7..3c6f197cc2 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -579,7 +579,7 @@ def require_peer(self) -> Thread: ) return self.second - def publish(self, changed: Collaboration): + def publish(self, changed: Collaboration) -> tuple[RelationshipDocument, Collaboration]: active = self.active if changed is active: return self.document, changed.oriented(self.first) @@ -593,19 +593,19 @@ def publish(self, changed: Collaboration): @dataclass(frozen=True) -class RelationshipEdit(Command, DeclaredFamily, affix="RelationshipEdit"): +class RelationshipEdit[Result]( + Command[RelationshipEditContext, tuple[RelationshipDocument, Result]], + DeclaredFamily, affix="RelationshipEdit", +): note: str = "" def __post_init__(self): if len(self.note) > 2000: raise ValueError("Collaboration notes are limited to 2000 characters") - @abstractmethod - def apply(self, ctx: RelationshipEditContext): ... - -class RetainingRelationshipEdit(RelationshipEdit): - def apply(self, ctx: RelationshipEditContext): +class RetainingRelationshipEdit(RelationshipEdit[Collaboration]): + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration]: peer = ctx.require_peer() return ctx.publish(self.change(ctx, peer)) @@ -631,8 +631,8 @@ def change(self, ctx: RelationshipEditContext, peer: Thread) -> Collaboration: return active.updated(self.note, time.time()) -class RemoveRelationshipEdit(RelationshipEdit): - def apply(self, ctx: RelationshipEditContext): +class RemoveRelationshipEdit(RelationshipEdit[None]): + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, None]: existing = ctx.existing if existing is None: return ctx.document, None diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index 5d7617ca27..0ee568addd 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -10,7 +10,7 @@ import asyncio import json from abc import abstractmethod -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable from contextlib import asynccontextmanager from dataclasses import dataclass from functools import partial @@ -67,7 +67,9 @@ async def send(self, line: OwnerLine) -> None: @dataclass(frozen=True, kw_only=True) -class RuntimeRequest(DeclaredFamily, Command, affix="RuntimeRequest"): +class RuntimeRequest( + DeclaredFamily, Command[RuntimeRequestContext, Awaitable[None]], affix="RuntimeRequest" +): thread: str @classmethod diff --git a/src/agent_comms/session_lifecycle.py b/src/agent_comms/session_lifecycle.py index 12a38cb1a9..54ddcd0b6a 100644 --- a/src/agent_comms/session_lifecycle.py +++ b/src/agent_comms/session_lifecycle.py @@ -6,7 +6,7 @@ import os from functools import partial from pathlib import Path -from typing import Any, cast +from typing import cast from acp import RequestError from acp.schema import ( diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index a0c5ec57d2..57f370c96a 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2739,8 +2739,12 @@ def closed(cls, current: Todo, order: ProblemOrder) -> Todo: revision=current.revision + 1) +# Todo stores these commands, so it is declared after them; the alias reads it lazily. +type CommandedTodo = Todo + + @dataclass(frozen=True) -class TodoCommand(Command, DeclaredFamily, affix="TodoOperation"): +class TodoCommand(Command[CommandedTodo, CommandedTodo], DeclaredFamily, affix="TodoOperation"): expected_revision: int def __post_init__(self) -> None: diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 029e0aad6a..16a88b7fc9 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -419,9 +419,10 @@ def sites(tree: ast.AST, filename: str) -> Iterator[ast.AST]: @staticmethod def occurrences(node: ast.AST) -> int: - metadata = typing.cast( - types.MemberDescriptorType, typing.cast(typing.Any, dataclasses.Field).metadata - ).__name__ + descriptor: types.MemberDescriptorType = inspect.getattr_static( + dataclasses.Field, "metadata" + ) + metadata = descriptor.__name__ return sum( typing.cast(int, MetadataKeys().visit(keyword.value)) for keyword in typing.cast(ast.Call, node).keywords