Skip to content
Closed
7 changes: 5 additions & 2 deletions src/agent_comms/channel_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,17 @@ def contexts(self, declaration, channel):
class NamedTarget(CommandTarget):
name: str
batchable = False
direct_names = ()
guidance_scope = ""
default_audience = MessageAudience.DIRECT

@property
def names(self) -> tuple[str, ...]:
return (self.name,)

@property
def direct_names(self) -> tuple[str, ...]:
return ()

@abstractmethod
def conversation_key(self, sender: str, snapshot: RegistryProvenance,
channel: type[ChannelConversation], direct: type[DMConversation]) -> Conversation: ...
Expand Down Expand Up @@ -157,7 +160,7 @@ def require_frozen_recipients(self, message, audience):
message.role.require_recipients(self, audience)

@property
def direct_names(self):
def direct_names(self) -> tuple[str, ...]:
return self.names

def canonical_name(self, canonical):
Expand Down
16 changes: 12 additions & 4 deletions src/agent_comms/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from aiohttp import web
from pydantic import JsonValue

from .channel_targets import CommandTarget
from .cli_commands import DeclaredOptions, option
from .cli_protocol import CoreError
from .comms import Comms, client_wire
Expand All @@ -57,11 +58,18 @@ class DirectorCaller(ThreadCaller):
"""claude-ai speaks for the owner: each message it sends a thread is a directive, recorded as a
request when that thread holds owner requests."""

def require_directive(self, comms: Comms, body: str) -> None:
"""A directive passes DirectiveText and needs this caller's rules review, drawn,
answered and under 20 minutes old."""
def require_directive(self, comms: Comms, target: str, body: str) -> None:
"""A directive passes DirectiveText, and its TO_USER families when the target names the
human user, and needs this caller's rules review, drawn, answered and under 20 minutes
old."""
also = tuple(
family
for name in CommandTarget.named(target).direct_names
for families in comms.registry.require(name).role.human(DirectiveText.TO_USER)
for family in families
)
due = tuple(rule.line() for rule in RulesDue.violations(comms, self.name))
if refused := (*DirectiveText.refused(body), *due):
if refused := (*DirectiveText.refused(body, *also), *due):
raise ValueError("Directive refused, nothing sent: " + " ".join(refused))

def sent(self, comms: Comms, message: Message) -> None:
Expand Down
89 changes: 86 additions & 3 deletions src/agent_comms/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,16 +753,99 @@ def found(cls, text: str) -> list[str]:
return [] if BluntText.found(cls.SLOP.sub(' ', text)) else cls.SLOP.findall(text)


class AuthorizedAct(DeclaredFamily, affix="Act"):
"""An act claude-ai is authorized to take without asking: a question to the user asking
whether to take it costs his attention and stalls the fleet. Product meaning and costly or
irreversible acts are not members, so questions about them still reach him."""

ASKS: ClassVar[str] = (
r"\b(?:(?:do you want me to|want me to|would you like me to|should (?:i|we)|shall i"
r"|may i|can i|ok(?:ay)? (?:for me )?to|permission to)\s+(?:{acts})\b[^?\n]*\?"
r"|let me know if you want me to\s+(?:{acts})\b)")
words: ClassVar[tuple[str, ...]]

@classmethod
def asked(cls, text: str) -> bool:
return bool(re.search(cls.ASKS.format(acts="|".join(cls.words)), text, re.I))


class SendAct(AuthorizedAct):
words = ("send", "post", "forward", "relay")


class EnforceAct(AuthorizedAct):
words = ("enforce",)


class StrikeAct(AuthorizedAct):
words = ("strike",)


class ReassignAct(AuthorizedAct):
words = ("reassign", "move")


class FixAct(AuthorizedAct):
words = ("fix", "repair", "patch")


class RegisterAct(AuthorizedAct):
words = ("register",)


class RestartAct(AuthorizedAct):
words = ("restart",)


class InstallAct(AuthorizedAct):
words = ("install",)


class MergeAct(AuthorizedAct):
words = ("merge",)


class KillAct(AuthorizedAct):
words = ("kill", "stop")


class ArchiveAct(AuthorizedAct):
words = ("archive",)


class RetryAct(AuthorizedAct):
words = ("retry", "clear")


class StartAct(AuthorizedAct):
words = ("start",)


class PermissionText(OrderWords):
"""A message to the user never asks whether to take an act claude-ai is authorized for."""

refusal = "authorized; act: {1} is yours to do, so do it and report."

@classmethod
def found(cls, text: str) -> list[str]:
spoken = cls.CITED.sub(' ', text)
return [act.declared_name for act in AuthorizedAct.members_with(AuthorizedAct)
if act.asked(spoken)]


class DirectiveText:
"""What the commander and the owner's voice send units: order send, order say and the
connector's send refuse a text failing any of these."""
connector's send refuse a text failing any of these. The owner's voice writing to the user
also passes TO_USER."""

OUTGOING: ClassVar[tuple[type[OrderWords], ...]] = (
FairText, HierarchyText, PlainText, OrderText, DefectText)
TO_USER: ClassVar[tuple[type[OrderWords], ...]] = (PermissionText,)

@classmethod
def refused(cls, text: str) -> tuple[str, ...]:
return tuple(refused for family in cls.OUTGOING if (refused := family.refused(text)))
def refused(cls, text: str, *also: type[OrderWords]) -> tuple[str, ...]:
return tuple(refused for family in (*cls.OUTGOING, *also)
if (refused := family.refused(text)))


class OrderRun(DeclaredFamily, affix="OrderRun"):
Expand Down
6 changes: 3 additions & 3 deletions src/agent_comms/tool_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def subject(self, named: str) -> str: ...
def may_reconfigure(self, comms: Comms, name: str) -> bool: ...

@abstractmethod
def require_directive(self, comms: Comms, body: str) -> None:
def require_directive(self, comms: Comms, target: str, body: str) -> None:
"""A caller whose sends direct units refuses a body its directive checks refuse."""

@abstractmethod
Expand Down Expand Up @@ -76,7 +76,7 @@ def subject(self, named: str) -> str:
def may_reconfigure(self, comms: Comms, name: str) -> bool:
return not comms.registry.status(name).active

def require_directive(self, comms: Comms, body: str) -> None:
def require_directive(self, comms: Comms, target: str, body: str) -> None:
"""A caller with no thread gives no directive."""

def sent(self, comms: Comms, message: Message) -> None:
Expand Down Expand Up @@ -104,7 +104,7 @@ def may_reconfigure(self, comms: Comms, name: str) -> bool:
return (not comms.registry.status(name).active
or comms.registry.canonical_name(name) == comms.registry.canonical_name(self.name))

def require_directive(self, comms: Comms, body: str) -> None:
def require_directive(self, comms: Comms, target: str, body: str) -> None:
"""A unit's or a script's send is a message, not a directive."""

def sent(self, comms: Comms, message: Message) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/agent_comms/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ class CommsSendTool(ToolRequest):
)

def apply(self, comms: Comms, caller: ToolCaller) -> MessagePublished:
caller.require_directive(comms, self.body)
caller.require_directive(comms, self.target, self.body)
body = VerdictAudience(self.audience).addressed(self.target, self.body)
result = comms.messaging.send(self.sender, self.target, body, self.type, report=self.report)
caller.sent(comms, result.message)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_commander_problem_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,35 @@ def todo_row(comms, problem):
return db.execute('SELECT * FROM todo WHERE id=?', (problem,)).fetchone()


def test_claude_ai_permission_seeking_register_or_restart_to_the_user_is_refused_and_product_questions_pass(
commander, tmp_path):
"""claude-ai asking the user whether to take an act it is authorized for, in any common way of
asking, is refused before anything is sent; a question about product meaning still reaches the
user, and the same permission question to a unit is a message to that unit."""
from agent_comms.connector import CALLER
from agent_comms.rule_reviews import AnsweredRuleReviewEntry, DrawnRuleReviewEntry, RuleReviews
from agent_comms.thread_identity import UserThreadRole
comms, _ = commander
comms.registry.declare(comms.registry.new_thread('claude-ai', frozenset(), str(tmp_path), created_at=3.0))
comms.registry.declare(comms.registry.new_thread(
'user', frozenset(), str(tmp_path), role=UserThreadRole, created_at=4.0))
RuleReviews(comms.root).write('claude-ai', DrawnRuleReviewEntry(('Declare each fact once',), 1))
RuleReviews(comms.root).write('claude-ai', AnsweredRuleReviewEntry(
1, 'This tick moves rules.py:47 into one typed Core record that Core owns.'))
def direct(target, body):
CommsSendTool.invoke(comms, {'from': 'claude-ai', 'to': target, 'body': body}, caller=CALLER)
return comms.bus.log.latest_sequence()
sent = comms.bus.log.latest_sequence()
for act, body in (('send', 'Want me to send the order?'),
('register', 'Should I register claude-ai on the desktop?'),
('restart', 'Would you like me to restart the connector?')):
with pytest.raises(ValueError, match=rf"authorized; act: \['{act}'\] is yours to do"):
direct('user', body)
assert comms.bus.log.latest_sequence() == sent
assert direct('user', 'Should P174 keep the old ledger format?') == sent + 1
assert direct('worker', 'Want me to send the order?') == sent + 2


def test_goal_stays_open_while_problems_pend(commander):
"""Delivering a problem never completes the Goal while another assigned problem pends, even one
stored under a stale generation; a revised order for one keeps the other's order and deadline."""
Expand Down
Loading