Skip to content
Open
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
8 changes: 5 additions & 3 deletions coworker/connectors/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ async def _on_update(update, _context):
if event is not None:
await self.handle_message(event)

self._app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, _on_update)
)
# NOT `& ~filters.COMMAND`: that discards every message beginning with "/",
# which is the only syntax people use to talk to a bot. With no CommandHandler
# registered either, /help and /status were dropped before anything saw them —
# no reply, no record, indistinguishable from a dead bot.
self._app.add_handler(MessageHandler(filters.TEXT, _on_update))
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(drop_pending_updates=True)
Expand Down
123 changes: 123 additions & 0 deletions coworker/connectors/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Slash commands for a bound chat — so the bot is somewhere you can ask things,
not just somewhere prompts arrive.

The rule this module exists to enforce: **an inbound message is never met with
silence.** A prompt mirrored to a phone is only half a conversation if the person
holding the phone can't ask what is waiting, can't tell whether their reply landed,
and gets nothing back when they type something the parser doesn't understand.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable, Optional

HELP = """Commands:
/pending - what is waiting on you, and how to answer it
/status - workers, routines, and anything stuck
/runs - how the last few scheduled runs went
/help - this message

To answer a prompt, reply with its tag:
approve [ow:...] - just this once
always [ow:...] - and stop asking for that routine
deny [ow:...] - refuse it"""


@dataclass
class PendingItem:
id: str
title: str
detail: str = ""
worker: str = ""
routine: str = ""
is_approval: bool = True


@dataclass
class RunLine:
routine: str
when: str
status: str


@dataclass
class ChatContext:
"""What the commands can see. A narrow view, passed in, so this module stays
testable without a server and can't reach anything it wasn't handed."""

pending: Callable[[], list[PendingItem]]
workers: Callable[[], list[str]]
routines: Callable[[], list[str]]
runs: Callable[[], list[RunLine]]


def _pending_block(items: list[PendingItem]) -> str:
if not items:
return "Nothing is waiting on you."
lines = [f"{len(items)} waiting:" if len(items) > 1 else "1 waiting:"]
for it in items:
who = it.worker or "a worker"
where = f" · {it.routine}" if it.routine else ""
lines.append(f"\n{who}{where}\n{it.title}")
if it.detail:
lines.append(it.detail)
# The exact strings to send — never make someone assemble a reply.
if it.is_approval:
lines.append(f"approve [ow:{it.id}] · always [ow:{it.id}] · deny [ow:{it.id}]")
else:
lines.append(f"Reply with your answer plus [ow:{it.id}]")
return "\n".join(lines)


def handle_command(text: str, ctx: ChatContext) -> Optional[str]:
"""The reply for a slash command, or None if this isn't one.

Tolerant on purpose: a leading slash is optional and a @botname suffix is
stripped, because that is what people actually type.
"""
raw = (text or "").strip()
if not raw:
return None
word = raw.split()[0].lower().lstrip("/").split("@")[0]

if word in {"help", "commands", "start"}:
return HELP

if word in {"pending", "waiting", "inbox", "queue"}:
return _pending_block(ctx.pending())

if word == "status":
workers, routines = ctx.workers(), ctx.routines()
pending = ctx.pending()
lines = [
f"{len(workers)} workers: {', '.join(workers)}" if workers else "No workers yet.",
]
lines += routines if routines else ["No routines."]
lines.append("")
lines.append(_pending_block(pending))
return "\n".join(lines)

if word in {"runs", "history"}:
runs = ctx.runs()
if not runs:
return "No scheduled runs recorded yet."
return "\n".join(f"{r.routine} · {r.when} · {r.status}" for r in runs)

return None


def unmatched_reply(text: str, ctx: ChatContext) -> str:
"""What to say when a message matched no command and no waiting prompt.

Silence was the old behaviour: the message was filed as unrouted and the sender
told nothing, which is indistinguishable from the bot being dead.
"""
items = ctx.pending()
if items:
return (
"I couldn't match that to anything. To answer what's waiting, send one of "
"these lines (the tag is how I know which prompt you mean):\n\n"
+ _pending_block(items)
)
return f"I couldn't match that to anything, and nothing is waiting on you.\n\n{HELP}"
143 changes: 141 additions & 2 deletions coworker/server/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from ..roots import RootDir
from ..workspace_trust import WorkspaceTrustStore
from ..automation import Schedule, ScheduledTask, Scheduler, TaskRun, TaskStore
from ..automation.models import rule_parts
from ..connectors import (
Gateway,
MessageSource,
Expand Down Expand Up @@ -171,6 +172,16 @@ def _grant_offered(outcome, request) -> bool:
return True


def _when(epoch: float) -> str:
"""A timestamp for a chat: the day and time, in the box's own zone."""
import datetime

try:
return datetime.datetime.fromtimestamp(epoch).strftime("%a %H:%M")
except Exception:
return "?"


def _approval_body(request) -> str:
"""Approval card body: the tool's reason (if any) plus a compact preview of its args, so a
mirrored 'Run `write_file`?' shows the path/content rather than just the tool name.
Expand Down Expand Up @@ -1084,7 +1095,13 @@ async def approve(request):
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resolution = await self.inbox.wait(item.id)
return self.approval_outcome(resolution, request, session_id)
outcome = self.approval_outcome(resolution, request, session_id)
# Say what happened, in the chat that asked. Answering a prompt and hearing
# nothing back is indistinguishable from the message not arriving — and for
# "always" the outcome is genuinely not knowable from the reply alone, since
# a grant can be refused for a tool that cannot carry one.
await self._ack_resolution(item, resolution, None, request)
return outcome

return approve

Expand Down Expand Up @@ -4439,10 +4456,39 @@ async def approver(request):
self.persist_session(session_id)
await self.mirror_inbox_item(item)
resolution = await self.inbox.wait(item.id)
return self.approval_outcome(resolution, request, session_id)
outcome = self.approval_outcome(resolution, request, session_id)
await self._ack_resolution(item, resolution, task, request)
return outcome

return approver

async def _ack_resolution(self, item, resolution: str, task, request) -> None:
"""Tell the bound chat what an answer actually did."""
binding = self.inbox_routing.binding_for(item.inbox)
if not (binding.channel and self.gateway is not None):
return
tool = getattr(request, "tool_name", "that")
who = task.title if task is not None else "the worker"
if resolution == "deny":
text = f"Declined — {who} did not run `{tool}`."
elif resolution == "always_task" and task is not None:
fresh = self.task_store.get(task.id)
granted = fresh is not None and tool in {
t for t, _ in map(rule_parts, fresh.always_allowed_tools)
}
text = (
f"Approved, and `{tool}` is now standing for {task.title} — it won't ask again."
if granted
else f"Approved once. `{tool}` can't hold a standing grant, so it will ask again."
)
else:
text = f"Approved — {who} is continuing."
target = f"{binding.channel}:{binding.target}"
try:
await self.gateway.deliver(target, text)
except Exception:
logger.debug("resolution ack failed", exc_info=True)

def _seed_task_permissions(self, engine: TurnEngine, task) -> None:
"""Apply a task's standing allowances to an engine: target-bound rules feed the
permission engine's matcher (connector tools included — the target binding is the
Expand Down Expand Up @@ -4567,6 +4613,87 @@ async def _on_interaction(self, event) -> None:
except Exception:
pass

# -- chat commands ----------------------------------------------------------
def _chat_context(self):
"""The narrow view of state that slash commands may read."""
from ..connectors.commands import ChatContext, PendingItem, RunLine
from ..inbox import KIND_APPROVAL

def pending() -> list:
out = []
for item in self.inbox.pending():
data = getattr(item, "data", None) or {}
out.append(
PendingItem(
id=item.id,
title=item.title,
detail=(item.body or "").strip().splitlines()[0] if item.body else "",
worker=self._session_agent_name(item.session_id),
routine=str(data.get("task_title") or ""),
is_approval=item.kind == KIND_APPROVAL,
)
)
return out

def workers() -> list[str]:
return [a.get("name") or a.get("id", "") for a in self.list_agents() if a.get("enabled", True)]

def routines() -> list[str]:
lines = []
for task in self.task_store.list():
state = "paused" if not task.enabled else (task.last_status or "not yet run")
lines.append(f"{task.title} · {task.schedule.human()} · {state}")
return lines

def runs() -> list:
out = []
for task in self.task_store.list():
for run in self.task_store.runs(task.id)[:3]:
out.append(
RunLine(
routine=task.title,
when=_when(run.started_at),
status=run.status or "?",
)
)
return out[:6]

return ChatContext(pending=pending, workers=workers, routines=routines, runs=runs)

def _session_agent_name(self, session_id: str) -> str:
record = self.session_store.load(session_id) if session_id else None
agent = getattr(record, "agent", "") if record else ""
for row in self.list_agents():
if row.get("id") == agent:
return row.get("name") or agent
return agent or ""

async def handle_chat_command(self, event, text: str) -> bool:
"""Answer a slash command in the chat it came from. True if handled."""
from ..connectors.commands import handle_command

reply = handle_command(text, self._chat_context())
if reply is None:
return False
await self._reply_in_chat(event, reply)
return True

async def _reply_in_chat(self, event, text: str) -> None:
source = getattr(event, "source", None)
if self.gateway is None or source is None:
return
from ..connectors.base import format_target

target = format_target(
getattr(source, "platform", ""),
str(getattr(source, "chat_id", "") or ""),
getattr(source, "thread_id", None),
)
try:
await self.gateway.deliver(target, text)
except Exception:
logger.debug("chat reply failed", exc_info=True)

# -- inbox replies over messaging connectors --------------------------------
def _resolve_inbox_reply(self, event) -> bool:
"""Try to handle an inbound Slack/Telegram message as an Inbox reply. Returns True if the
Expand Down Expand Up @@ -4770,6 +4897,11 @@ async def _dispatch_inbound(self, event) -> None:
pass
return
return # channel with no subscribers — nobody is listening
# A slash command is answered here, in the chat it came from — before any
# routing, because "what is waiting on me?" is a question about this bot, not
# something to hand to an agent.
if await self.handle_chat_command(event, text):
return
# DM (or any non-channel): route to the designated session, else park it for visibility.
dm = self.dm_session()
if dm and self._inbound_connector_allowed(dm, src.platform):
Expand All @@ -4779,10 +4911,17 @@ async def _dispatch_inbound(self, event) -> None:
self.unrouted.record(
src.target, who, text, reason="connector muted for DM session"
)
await self._reply_in_chat(event, "That worker isn't listening to this chat right now.")
else:
# Parked for the app, AND answered here. Silence was the old behaviour: a
# message vanished with no reply, which is indistinguishable from a dead bot
# — and it happened to every "approve" that arrived without its tag.
self.unrouted.record(
src.target, who, text, reason="no DM session designated"
)
from ..connectors.commands import unmatched_reply

await self._reply_in_chat(event, unmatched_reply(text, self._chat_context()))

# -- mention router (§31) ----------------------------------------------------
async def _route_mention(self, event, ms: MessageSource, subs) -> None:
Expand Down
Loading