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
18 changes: 14 additions & 4 deletions src/capabilities/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,23 @@ If Sam doesn't have substance, Sam stays quiet. Silence is not rude; noise is.

### One exception — Slack-triggered sessions MUST close the loop

When the session was triggered by a Slack message (not a scheduled wake-up, not a retry), Sam **must** close the loop — meaning a `chat.postMessage` / `chat.update` to the originating thread must come AFTER the last substantive outward-facing tool call in the session. Not just *any* post: an ACK at the start followed by work + silence is NOT a closed loop. The operator gets a quick "got it" and then hears nothing about the result.
When the session was triggered by a Slack message (not a scheduled wake-up, not a retry), Sam **must** close the loop — the operator should hear the result of the work, not just an ACK at the start.

"Substantive outward-facing" = anything the operator would expect a report for: `gh pr create / edit / merge`, `consult_opus`, `worker` / `parallel_workers`, `edit_file` / `write_file` outside `/data/journal/`, `fetch_url`. Internal reads (`read_file`, `grep`, `glob_files`), journal writes, and Slack housekeeping (`setStatus`, `reactions.add`, `conversations.replies`) don't count — they don't need a corresponding report.
The canonical way to close the loop is the **`respond(text: str)`** tool. `respond` posts to the originating thread via `chat.postMessage` and is the structured signal that satisfies the silent-exit gate. Calling it once anywhere in the session is enough — ordering doesn't matter, and cleanup work after the call (rm /tmp/x, tail /data/sessions.jsonl, journal writes) is harmless.

The daemon enforces this structurally. After a clean exit, if the session was Slack-triggered and `closed_loop` is False (no post, OR the last post came before more substantive work), the daemon spawns a one-shot retry whose only job is to read the previous session's audit-log slice and post the summary. Exiting silent doesn't save anything — it adds a retry round-trip and surfaces in the logs as a silent-exit recovery, which the operator can see.
```
respond(text="*Multi-channel refinements (PR #79)*\n- eliminated hardcoding: ...\n- mention bypass anywhere: ...")
```

The honest move is: post the summary as the *final* outward action of the session — after the last `gh pr create`, after `consult_opus` returns, after the last source edit. Lead with consequence, cite PR/issue IDs at the end of lines. Don't let the artifacts do the talking — Slack is where the operator lives.
The text is auto-cleaned for Slack mrkdwn: `### Heading` becomes `*Heading*`, standalone `---` lines become blank lines. ALL CAPS section labels and `Sure!/Got it!` preambles trigger warnings in the logs but aren't rewritten — write in your normal voice.

**Bash-curl to `chat.postMessage` / `chat.update` still works.** Sam can experiment with new Slack endpoints via bash; `respond` is canonical, not exclusive. The fallback rule still applies: if Sam closes the loop via bash, a `chat.postMessage` / `chat.update` must come AFTER the last substantive outward-facing tool call (worker/parallel_workers, fetch_url, consult_opus, non-Slack bash, write/edit_file outside `/data/journal/`). Internal reads and journal writes don't count.

When Sam reaches for a bash pattern that `respond` (or any other existing tool) can't handle, that's a tier-3-promotion signal — daily-maintenance already audits bash command shapes (`src/skills/daily-maintenance/skill.md`, "Aggregation rule") and proposes extractions when the pattern recurs across sessions and isn't already covered. Bash patterns that hit a *daemon/runtime* limitation (silent-exit gate, image pipeline, audit redaction) are the strongest tier-3 candidates and should be surfaced explicitly in the daily broadcast.

After a clean exit, if neither path satisfied the gate, the daemon spawns a one-shot retry whose only job is to read the previous session's audit-log slice and call `respond` with the summary. Exiting silent doesn't save anything — it adds a retry round-trip and surfaces in the logs.

The honest move is: call `respond` as the final substantive action of the session — after the last `gh pr create`, after `consult_opus` returns, after the last source edit. Lead with consequence, cite PR/issue IDs at the end of lines.

## Threading

Expand Down
170 changes: 170 additions & 0 deletions src/runtime/adk_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,161 @@ async def ask_operator(question: str) -> str:
return ask_operator


def _clean_for_slack_mrkdwn(text: str) -> tuple[str, list[str]]:
"""Auto-remediate the mechanical mrkdwn rules + collect soft warnings.

Slack doesn't render `###` headings or `---` horizontal rules; both
show up as raw text and look like a bug to the operator. We rewrite
those deterministically — the LLM keeps the semantic intent (heading
or section break) without having to remember the exact mrkdwn syntax.

Soft drift cases (ALL CAPS labels, "Sure!"/"Got it!" preambles) are
collected as warnings but NOT rewritten — Sam's prose may have a
reason, and the operator can grep the journal/audit for these later
to decide whether to promote to a hard rule. The user's stance is
"polish is okay, don't lock" — over-correction destroys voice.

Returns (cleaned_text, warnings). Warnings are logged at the call
site; they're never returned to Slack.
"""
import re

warnings: list[str] = []
out_lines: list[str] = []
for raw in text.split("\n"):
line = raw

# Markdown headings → bold line. `### Heading` → `*Heading*`.
# Bold preserves the semantic anchor without rendering as raw
# text in Slack.
m = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if m:
line = f"*{m.group(2)}*"

# Horizontal rules → blank line. Empty separators read fine in
# Slack; `---` reads as raw text and signals "I forgot Slack
# mrkdwn syntax".
if re.match(r"^\s*(---+|\*\*\*+|___+)\s*$", line):
line = ""

# Soft warnings — not auto-rewritten. Caller logs.
if re.search(r"^\s*\*[A-Z][A-Z0-9 _#()/-]{2,}\*\s*$", line):
warnings.append(f"ALL CAPS label detected: {line.strip()!r}")
for opener in ("Sure!", "Got it!", "Absolutely!", "Of course!"):
if line.lstrip().startswith(opener):
warnings.append(f"banned preamble {opener!r} at line start")
break

out_lines.append(line)

return "\n".join(out_lines), warnings


def _make_respond_tool(
*,
channel: Optional[str],
thread_ts: Optional[str],
event_ts: Optional[str],
):
"""Return a coroutine that posts Sam's close-the-loop reply to the
originating Slack thread and signals the session's gate as satisfied.

`respond` is the structured contract that replaces the regex-on-bash
silent-exit gate. The classifier in `session.py:_classify_tool_use`
treats a `respond` tool call as definitive "Sam closed the loop" —
no timing inference, no substring matching, no ordering against
outward bash. Cleanup work after `respond` (rm /tmp/x, tail
/data/sessions.jsonl) does not trip the gate because the gate reads
the call, not the trace.

Only the MAIN agent gets this tool. Workers/pro_executor/mentor
return to their caller, not the operator — same scoping as
`ask_operator`.

Channel/thread_ts come from the IncomingMessage and are bound at
closure-time so the LLM signature is just `respond(text: str)`.
For scheduled / synthetic / retry sessions without an originating
thread, the tool errors gracefully and the LLM falls back to
bash-curl (or the daily-maintenance silent-exit allowance).

Bash to chat.postMessage still works. `respond` is canonical, not
exclusive — the operator wants standardization without restriction.
The bash fallback path keeps the legacy regex classifier as a
best-effort gate; sessions that close via bash get retries only when
the bash pattern hits a classifier hole (heredoc, helper script,
cleanup-after-post).
"""
async def respond(text: str) -> str:
"""Post Sam's close-the-loop reply to the originating Slack thread.

Call this exactly once per Slack-triggered session as Sam's final
substantive reply — the wrap-up that the operator should see.
ACK / status / mid-session updates remain free-form (bash curl
or whatever); `respond` is specifically the "I'm done, here's
the result" message that satisfies the silent-exit gate.

The text is light-cleaned before posting: `### Heading` becomes
`*Heading*`, standalone `---` lines become blank. ALL CAPS labels
and `Sure!/Got it!` preambles are logged as warnings but kept
as-is. Write in your normal voice — terse, sentence-case OK,
lowercase OK, lead with the consequence. The Slack formatting
rules in `src/capabilities/slack.md` still apply; this tool just
handles the two mechanical rules that drift most often.

text: the Slack message body in mrkdwn. Use `*bold*` for emphasis,
`_italic_`, `` `code` ``, `- ` for bullets (flat only — Slack
doesn't render nested lists), `<url|label>` for links.

Returns a sentinel string the LLM can ignore — the work is done.
"""
import aiohttp
from .config import SLACK_BOT_TOKEN

if not channel:
return (
"(respond unavailable: no originating channel — this "
"session has no Slack thread to post into. If this is a "
"scheduled/cron session that has nothing worth saying, "
"just exit; silence is allowed here. Otherwise post via "
"bash-curl to chat.postMessage and accept that the "
"silent-exit gate falls back to the regex classifier.)"
)
if not SLACK_BOT_TOKEN:
return "(respond unavailable: SLACK_BOT_TOKEN not set. Fall back to bash-curl.)"

cleaned, warnings = _clean_for_slack_mrkdwn(text)
for w in warnings:
log.warning("respond: voice warning — %s", w)

target_thread_ts = thread_ts or event_ts
post_body = {"channel": channel, "text": cleaned}
if target_thread_ts:
post_body["thread_ts"] = target_thread_ts
headers = {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json; charset=utf-8",
}

try:
async with aiohttp.ClientSession() as http:
async with http.post(
"https://slack.com/api/chat.postMessage",
headers=headers,
json=post_body,
) as resp:
post_json = await resp.json()
if not post_json.get("ok"):
err = post_json.get("error", "unknown")
return f"(respond failed at chat.postMessage: {err}. Try bash-curl as fallback.)"
except Exception as exc:
log.exception("respond HTTP call failed")
return f"(respond HTTP error: {type(exc).__name__}: {exc}. Try bash-curl as fallback.)"

return f"Posted to {channel} (ts={post_json.get('ts')}). Loop closed."

return respond


# ─── ADK runner ───────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -971,6 +1126,20 @@ async def run(self, request: AgentRunRequest) -> AgentRunResult:
event_ts=request.slack_event_ts,
)

# `respond` — the structured close-the-loop tool. Posts via
# chat.postMessage to the originating thread; its mere call
# satisfies the silent-exit gate (no regex on bash, no ordering
# against outward calls). Cleanup work after `respond` is
# harmless. Only the main agent gets it — workers must not
# post to Slack. Bash-curl to chat.postMessage still works as
# a fallback for new/experimental Slack endpoints; `respond`
# is canonical, not exclusive.
respond_tool = _make_respond_tool(
channel=request.slack_channel,
thread_ts=request.slack_thread_ts,
event_ts=request.slack_event_ts,
)

# `consult_opus` — dispatch the mentor (Opus, read-only) to review
# accumulated context. Sam DOES NOT decide to invoke autonomously —
# only the `daily-maintenance` skill (review step) and the
Expand All @@ -997,6 +1166,7 @@ async def run(self, request: AgentRunRequest) -> AgentRunResult:
AgentTool(agent=pro_executor_agent),
FunctionTool(func=consult_opus_tool),
FunctionTool(func=ask_operator_tool),
FunctionTool(func=respond_tool),
],
)

Expand Down
62 changes: 61 additions & 1 deletion src/runtime/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,46 @@ async def _worker(self) -> None:
if not message.scheduled:
save_cursor(message.event_ts)

async def _bot_posted_in_thread_since(
self,
*,
channel: str,
thread_ts: str,
since_wall: float,
) -> Optional[bool]:
"""Ground-truth check: did this bot post in `thread_ts` since `since_wall`?

Returns True / False if the Slack API answered, or None if the
query itself failed (in which case the caller should default to
the conservative behavior — alert as today).

Used by `_post_operator_alert` to suppress the "something's wrong
with me" message when the classifier inferred no closed loop but
Slack actually shows Sam posted via some other path (bash curl,
helper script the regex missed, etc.). The pre-`respond`-tool
cascade fired here three times on 2026-05-25 with Sam having
actually posted via bash; the regex called it silent, the alert
fired, the operator saw a false alarm.
"""
if not self.bot_user_id:
# Can't filter to "this bot's" posts without knowing our own id.
return None
try:
resp = await self.app.client.conversations_replies(
channel=channel,
ts=thread_ts,
oldest=f"{since_wall:.6f}",
limit=200,
)
except Exception:
log.exception("conversations.replies failed during alert-suppression check")
return None
messages = resp.get("messages") or []
for m in messages:
if m.get("user") == self.bot_user_id:
return True
return False

async def _post_operator_alert(
self,
original: IncomingMessage,
Expand All @@ -1491,7 +1531,27 @@ async def _post_operator_alert(

No claude subprocess — this has to work even when claude itself is
the thing that's broken. Keep the text short and concrete.

Ground-truth guard: before posting, query Slack for bot posts in
the originating thread since the first session started. If the
bot DID post (via bash curl, helper script, or any path the
classifier missed), suppress the alert — the operator already
heard from Sam. Only fire when Slack confirms zero bot posts.
"""
target_thread_ts = original.thread_ts or original.event_ts
bot_posted = await self._bot_posted_in_thread_since(
channel=original.channel,
thread_ts=target_thread_ts,
since_wall=first.started_at_wall,
)
if bot_posted is True:
log.info(
"operator-alert suppressed: bot posted in %s thread since %.3f — "
"classifier said silent, Slack disagrees",
original.channel, first.started_at_wall,
)
return

mention = f"<@{SAM_OPERATOR_USER_ID}> " if SAM_OPERATOR_USER_ID else ""
first_status = (
"stuck" if first.stuck
Expand All @@ -1513,7 +1573,7 @@ async def _post_operator_alert(
try:
await self.app.client.chat_postMessage(
channel=original.channel,
thread_ts=original.thread_ts or original.event_ts,
thread_ts=target_thread_ts,
text=redact_secrets(text),
)
except Exception:
Expand Down
36 changes: 22 additions & 14 deletions src/runtime/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@
"Do not retry the original task. Your one job in this session is:\n"
"\n"
"1. Read the failure context below.\n"
"2. Post ONE reply in the original Slack thread (channel={channel}, "
"thread_ts={thread_target}) in your normal Slack voice. Name what failed "
"in human terms, name the likely cause, and suggest a concrete fix. "
"Don't dump stderr verbatim — read it, summarise it.\n"
"2. Call `respond(text=...)` ONCE to post your reply to the original "
"Slack thread (channel={channel}, thread_ts={thread_target}). Use your "
"normal Slack voice. Name what failed in human terms, name the likely "
"cause, and suggest a concrete fix. Don't dump stderr verbatim — read "
"it, summarise it.\n"
"3. Then stop. This is a one-shot. The daemon will not retry again."
)

RETRY_SESSION_OUTRO = (
"Remember: post ONCE, in the original thread, in your Slack voice. "
"Be honest about the failure. Suggest a fix if you can see one. "
"Prefer the synthetic errors above over stderr when identifying the cause. "
"Then exit."
"Remember: call `respond` ONCE — that closes the loop. Be honest about "
"the failure. Suggest a fix if you can see one. Prefer the synthetic "
"errors above over stderr when identifying the cause. Then exit. Do "
"NOT do bash cleanup afterwards that touches the network or non-/data "
"paths — `respond` already satisfies the gate, but a final discipline "
"of 'one call, then exit' keeps the audit log clean."
)

SILENT_EXIT_INTRO = (
Expand All @@ -61,12 +64,17 @@
"not infer from journal text alone, since the previous session is the "
"exact failure mode that motivated this retry: journal entries claimed "
"things that never actually happened.\n"
"2. **Post ONE reply in the original Slack thread** (channel="
"{channel}, thread_ts={thread_target}) summarizing what was done. "
"2. **Call `respond(text=...)` ONCE** to post the summary in the "
"original Slack thread (channel={channel}, thread_ts={thread_target}). "
"Consequence-first: what the operator should do with the information, "
"not a chronological log. PR / issue IDs go at the end of the relevant "
"lines as references, not as headlines. Match the formatting rules in "
"`src/capabilities/slack.md` (bold sentence-case labels, flat lists).\n"
"`src/capabilities/slack.md` (bold sentence-case labels, flat lists). "
"`respond` is the structured close-the-loop tool — the gate reads its "
"call directly, so cleanup work after is harmless. Do NOT post via "
"`bash python3 /tmp/x.py` or other helper-script indirection; that's "
"the pattern that caused the previous session's silent-exit in the "
"first place.\n"
"3. **Then stop.** This is a one-shot. The daemon will not retry again.\n"
"\n"
"What you do NOT do:\n"
Expand All @@ -82,9 +90,9 @@
)

SILENT_EXIT_OUTRO = (
"Remember: post ONCE, in the original thread, in your Slack voice. "
"Cite PR/issue IDs as references at the end of lines, not as the "
"headline. Consequence-first. Then exit."
"Remember: call `respond` ONCE — that closes the loop. Cite PR/issue "
"IDs as references at the end of lines, not as the headline. "
"Consequence-first. Then exit. No cleanup bash afterwards."
)

CONTINUATION_INTRO = (
Expand Down
Loading
Loading