From ddb9c59dcd0619927270470a50036752b8febff5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 07:48:10 -0400 Subject: [PATCH 1/3] Read dataclass metadata descriptor without erasing its type --- tools/debt_ratchet.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 6db117361d..c9e92a9a5f 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -418,9 +418,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 From 313fe3d30c56001da70d2608dbbe598e9541d901 Mon Sep 17 00:00:00 2001 From: Ratchet test Date: Fri, 23 Oct 2026 09:01:42 -0400 Subject: [PATCH 2/3] Type Command by each root's declared context and result Command.apply took `ctx: Any -> Any`, so every command family's context and result lived only in the subclass overrides and the base contract said nothing. Command is now generic over Context and Result, and each root binds the types its members already declare: - RuntimeRequest: RuntimeRequestContext -> Awaitable[None] - TargetEdit: Comms -> CliResult - CliExecution/CliCommand: Comms -> Answer, the family's own type parameter, so a command's declared answer is also its Command result - ConnectorVerb: ConnectorStore -> int | str - RelationshipEdit: RelationshipEditContext -> tuple[RelationshipDocument, Collaboration | None]; its three apply overrides now say so instead of returning unannotated - GoalAction[Result]: GoalActionContext -> Result - TodoCommand: Todo -> Todo - NativePackageVerb: None -> None Also drops the unused Any imports in session_lifecycle.py and queued_input.py. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/cli_commands.py | 6 +++--- src/agent_comms/cli_native_package.py | 2 +- src/agent_comms/command.py | 6 +++--- src/agent_comms/connector.py | 2 +- src/agent_comms/goal_actions.py | 4 +++- src/agent_comms/queued_input.py | 2 +- src/agent_comms/relationships.py | 11 +++++++---- src/agent_comms/runtime_requests.py | 4 ++-- src/agent_comms/session_lifecycle.py | 2 +- src/agent_comms/todos.py | 2 +- 10 files changed, 23 insertions(+), 18 deletions(-) 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..ade1fadaf1 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -593,7 +593,10 @@ def publish(self, changed: Collaboration): @dataclass(frozen=True) -class RelationshipEdit(Command, DeclaredFamily, affix="RelationshipEdit"): +class RelationshipEdit( + Command[RelationshipEditContext, tuple[RelationshipDocument, Collaboration | None]], + DeclaredFamily, affix="RelationshipEdit", +): note: str = "" def __post_init__(self): @@ -601,11 +604,11 @@ def __post_init__(self): raise ValueError("Collaboration notes are limited to 2000 characters") @abstractmethod - def apply(self, ctx: RelationshipEditContext): ... + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration | None]: ... class RetainingRelationshipEdit(RelationshipEdit): - def apply(self, ctx: RelationshipEditContext): + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration | None]: peer = ctx.require_peer() return ctx.publish(self.change(ctx, peer)) @@ -632,7 +635,7 @@ def change(self, ctx: RelationshipEditContext, peer: Thread) -> Collaboration: class RemoveRelationshipEdit(RelationshipEdit): - def apply(self, ctx: RelationshipEditContext): + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration | 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..756941430c 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,7 @@ 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..7cbc400bf1 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2740,7 +2740,7 @@ def closed(cls, current: Todo, order: ProblemOrder) -> Todo: @dataclass(frozen=True) -class TodoCommand(Command, DeclaredFamily, affix="TodoOperation"): +class TodoCommand(Command["Todo", "Todo"], DeclaredFamily, affix="TodoOperation"): expected_revision: int def __post_init__(self) -> None: From a75d4fa2fdd64f62673454d2e127f148430a18e6 Mon Sep 17 00:00:00 2001 From: Ratchet test Date: Fri, 23 Oct 2026 09:10:10 -0400 Subject: [PATCH 3/3] Bind each relationship edit's own result; read Todo through a lazy alias debt-src caught the first cut restating facts: - RelationshipEdit bound `Collaboration | None` as every member's result, which is the union of two members' answers written on the parent. Retaining edits always publish a Collaboration; removal always answers None. The family is now generic in its result, like GoalAction, and each branch binds its own. The abstract apply that restated Command's is deleted. - TodoCommand spelled Todo as "Todo" strings. Todo stores these commands, so it must be declared after them; a PEP 695 alias reads it lazily instead of binding by spelling. - The RuntimeRequest header is wrapped to the line limit. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/relationships.py | 17 +++++++---------- src/agent_comms/runtime_requests.py | 4 +++- src/agent_comms/todos.py | 6 +++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index ade1fadaf1..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,8 +593,8 @@ def publish(self, changed: Collaboration): @dataclass(frozen=True) -class RelationshipEdit( - Command[RelationshipEditContext, tuple[RelationshipDocument, Collaboration | None]], +class RelationshipEdit[Result]( + Command[RelationshipEditContext, tuple[RelationshipDocument, Result]], DeclaredFamily, affix="RelationshipEdit", ): note: str = "" @@ -603,12 +603,9 @@ def __post_init__(self): if len(self.note) > 2000: raise ValueError("Collaboration notes are limited to 2000 characters") - @abstractmethod - def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration | None]: ... - -class RetainingRelationshipEdit(RelationshipEdit): - def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration | None]: +class RetainingRelationshipEdit(RelationshipEdit[Collaboration]): + def apply(self, ctx: RelationshipEditContext) -> tuple[RelationshipDocument, Collaboration]: peer = ctx.require_peer() return ctx.publish(self.change(ctx, peer)) @@ -634,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) -> tuple[RelationshipDocument, Collaboration | None]: +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 756941430c..0ee568addd 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -67,7 +67,9 @@ async def send(self, line: OwnerLine) -> None: @dataclass(frozen=True, kw_only=True) -class RuntimeRequest(DeclaredFamily, Command[RuntimeRequestContext, Awaitable[None]], affix="RuntimeRequest"): +class RuntimeRequest( + DeclaredFamily, Command[RuntimeRequestContext, Awaitable[None]], affix="RuntimeRequest" +): thread: str @classmethod diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 7cbc400bf1..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["Todo", "Todo"], DeclaredFamily, affix="TodoOperation"): +class TodoCommand(Command[CommandedTodo, CommandedTodo], DeclaredFamily, affix="TodoOperation"): expected_revision: int def __post_init__(self) -> None: