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
2 changes: 1 addition & 1 deletion docs/system-specs/modules/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ glue or a provider selector (see the repo-root `CLAUDE.md`).
- `approve_tool()`/`reject_tool()` → JSON-RPC response
- `context_usage_pct()` → reads `last_prompt_stats.context_pct`
- `context_window_tokens()` → reads `last_prompt_stats.context_window_tokens` (the real served window from `usage_update.size`, 0 if unknown). Used by the dashboard token text instead of re-deriving the window from the model id. A mid-session `set_model` (live switch on both `AcpClient` and `AcpSessionHandle`) rebases these stats via `AcpPromptStats.rebase_to_window`: the window is re-derived from `model_registry.model_window` (0 on a registry miss), `context_used_tokens` is kept, `context_pct` is recomputed and clamped, and `context_tokens_from_usage` is cleared so the next metadata `contextUsagePercentage` can backfill against the NEW model instead of being gated forever by the old model's `usage_update`. The dashboard model-switch endpoint then broadcasts one `context_usage` WS event with `reset: true` (both live-switch and session-reset paths, single and bulk), which lets the frontend reducer replace or delete its stored per-slot token counts — per-turn events without `reset` never delete. The post-compaction pct-0 broadcast carries the same flag.
- `compact()` → sends `/compact` via `send_command()`. The **dashboard's** manual `/compact` gates on `ACP_BACKENDS_COMPACT` first, as a pre-acquisition local command: the live session's `manual_compact_unsupported_backend` capability property (declared on the `LLMProvider` ABC with a `None` (supported) default per harness-parity H14, answered by the ACP implementations from set membership) is peeked when a session exists, else the same `agent.acp_backend` config the factory would build one with — so a refused `/compact` behaves as if the turn never started (no session created, no Slack OPTIONS expired, no one-shot turn state consumed). The reply is informational — the backend manages compaction automatically, mirroring the `cc_managed` relationship — not an error: kiro-cli answers the prompt with `_kiro.dev/compaction/status` and claude-agent-acp compacts natively in-prompt, but KAS treats the prompt as ordinary text and never emits a status, so an ungated manual `/compact` would strand `wait_for_compaction()` for the full `COMPACT_WAIT_TIMEOUT_SECS` (#7800). The **auto-compact** path consults the same capability from the compaction gate ladder (`session_compaction._compact_unsupported_backend`) and declines with `"compact_unsupported"` before the compaction task is scheduled, so no `/compact` is dispatched and the turn semaphore is never acquired — an ungated dispatch stranded the status wait for the whole `COMPACT_WAIT_TIMEOUT_SECS` while HOLDING that semaphore and then recycled the session (#7812). The messaging-surface `/compact` commands are still **not yet gated**: Slack, Telegram, Discord, Webex, Teams, WeCom, Weixin and WhatsApp all call `provider.compact()` with no capability check — tracked in #8156. The gate covers only the user-typed command — KAS auto-summarization frames keep mapping to compaction status.
- `compact()` → sends `/compact` via `send_command()`. The **dashboard's** manual `/compact` gates on `ACP_BACKENDS_COMPACT` first, as a pre-acquisition local command: the live session's `manual_compact_unsupported_backend` capability property (declared on the `LLMProvider` ABC with a `None` (supported) default per harness-parity H14, answered by the ACP implementations from set membership) is peeked when a session exists, else the same `agent.acp_backend` config the factory would build one with — so a refused `/compact` behaves as if the turn never started (no session created, no Slack OPTIONS expired, no one-shot turn state consumed). The reply is informational — the backend manages compaction automatically, mirroring the `cc_managed` relationship — not an error: kiro-cli answers the prompt with `_kiro.dev/compaction/status` and claude-agent-acp compacts natively in-prompt, but KAS treats the prompt as ordinary text and never emits a status, so an ungated manual `/compact` would strand `wait_for_compaction()` for the full `COMPACT_WAIT_TIMEOUT_SECS` (#7800). The **auto-compact** path consults the same capability from the compaction gate ladder (`session_compaction._compact_unsupported_backend`) and declines with `"compact_unsupported"` before the compaction task is scheduled, so no `/compact` is dispatched and the turn semaphore is never acquired — an ungated dispatch stranded the status wait for the whole `COMPACT_WAIT_TIMEOUT_SECS` while HOLDING that semaphore and then recycled the session (#7812). The **messaging-surface** `/compact` commands (Slack, Telegram, Discord, Webex, Teams, Feishu, iMessage, WeCom, Weixin and WhatsApp) gate on the same capability through `messaging.commands.compact_unsupported_backend` before dispatching, answering with `compact_unsupported_reply` (translated on the Chinese-language surfaces, plain-voiced on iMessage and WhatsApp); their context-threshold notices decline silently on such a backend — no forced hard-threshold compaction to run, and no soft nudge whose `/compact` advice cannot work (#8156). Gating covers only command dispatch — KAS auto-summarization frames keep mapping to compaction status.
- `cancel()` → sends `session/cancel` notification
- `supports_effort()` / `change_effort(level)` / `clear_effort()` → reasoning-effort control (see below)
- `is_alive()` → `AcpClient.is_responsive()` (600s stale threshold)
Expand Down
20 changes: 19 additions & 1 deletion src/kiro_crew/discord/transport_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@
from kiro_crew.hooks import TOOL_AUTO_APPROVE, TOOL_DENY
from kiro_crew.messaging.attachments import IngestLimits
from kiro_crew.messaging.attachments import cleanup as cleanup_attachments
from kiro_crew.messaging.commands import stop_running_turn
from kiro_crew.messaging.commands import (
compact_unsupported_backend,
compact_unsupported_reply,
stop_running_turn,
)
from kiro_crew.messaging.dispatch import (
build_auto_approve,
build_directive_consumer,
Expand Down Expand Up @@ -1646,6 +1650,11 @@ async def _maybe_notice(
"""
pct = self.sessions.check_context_usage(session_key, provider)
soft_pct = self.cfg.discord.soft_threshold_pct
if pct >= soft_pct and compact_unsupported_backend(provider):
# Capability gate (#8156): the nudge advises !compact, which this
# backend refuses — it compacts on its own as context fills, so
# there is nothing for the user to act on.
return
if pct >= soft_pct and not self._conv.is_awaiting(scope_id):
self._conv.set_awaiting(scope_id)
assert self.client is not None
Expand Down Expand Up @@ -1680,6 +1689,15 @@ async def _handle_compact(
await self.client.send_message(channel_id, "No active session to compact.")
return

# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# 120s wait below. Informational, never an error.
unsupported = compact_unsupported_backend(provider)
if unsupported:
await self.client.send_message(channel_id, compact_unsupported_reply(unsupported))
return

status_id = await self.client.send_message(channel_id, "🔄 Compacting context…")
result_text: str | None = None

Expand Down
22 changes: 22 additions & 0 deletions src/kiro_crew/feishu/transport_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from kiro_crew.feishu.renderer import FeishuRenderer
from kiro_crew.feishu.transport import FEISHU_CAPABILITIES
from kiro_crew.history import mint_row_mid
from kiro_crew.messaging.commands import compact_unsupported_backend
from kiro_crew.messaging.conversation import ConversationState
from kiro_crew.messaging.dispatch import (
ChannelTurn,
Expand Down Expand Up @@ -264,6 +265,19 @@ async def _handle_compact(self, inbound: "LarkInbound") -> None:
if provider is None:
await self.client.send_reply(inbound.message_id, "ℹ️ 当前没有可压缩的对话。")
return
# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# unbounded wait below. Informational (this surface speaks Chinese;
# the wording translates ``compact_unsupported_reply``), never an
# error.
unsupported = compact_unsupported_backend(provider)
if unsupported:
logger.debug("Feishu: manual /compact declined — %s compacts itself", unsupported)
await self.client.send_reply(
inbound.message_id, "ℹ️ 当前后端会自动压缩上下文,无需手动 /compact。"
)
return
await provider.compact()
await provider.wait_for_compaction()
await self.client.send_reply(inbound.message_id, "🗜️ 已压缩上下文。")
Expand Down Expand Up @@ -358,6 +372,14 @@ async def _maybe_notice(self, inbound: "LarkInbound", session_key: str, provider
assert self.client is not None
route = self._route(inbound)
pct = self.sessions.check_context_usage(session_key, provider)
if pct >= self.cfg.feishu.soft_threshold_pct:
# Capability gate (#8156): no forced compaction to run and the
# soft nudge's /compact advice cannot work — the backend compacts
# on its own as context fills.
unsupported = compact_unsupported_backend(provider)
if unsupported:
logger.debug("Feishu: context notice skipped — %s compacts itself", unsupported)
return
if pct >= self.cfg.feishu.hard_threshold_pct:
self._conv.clear_awaiting(route)
try:
Expand Down
23 changes: 23 additions & 0 deletions src/kiro_crew/imessage/transport_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from kiro_crew.imessage.renderer import IMessageRenderer
from kiro_crew.imessage.rpc import RpcError, RpcTransportError
from kiro_crew.imessage.transport import IMESSAGE_CAPABILITIES
from kiro_crew.messaging.commands import compact_unsupported_backend
from kiro_crew.messaging.dispatch import (
ChannelTurn,
build_directive_consumer,
Expand Down Expand Up @@ -310,6 +311,14 @@ async def _maybe_notice(
assert self.client is not None
handle = inbound.handle
pct = self.sessions.check_context_usage(session_key, provider)
if pct >= self.cfg.imessage.soft_threshold_pct:
# Capability gate (#8156): no forced compaction to run and the
# soft nudge's /compact advice cannot work — the backend compacts
# on its own as context fills.
unsupported = compact_unsupported_backend(provider)
if unsupported:
logger.debug("imessage: context notice skipped — %s compacts itself", unsupported)
return
if pct >= self.cfg.imessage.hard_threshold_pct:
self._conv.clear_awaiting(handle)
try:
Expand Down Expand Up @@ -352,6 +361,20 @@ async def _handle_compact(self, inbound: "IMessageInbound") -> None:
if provider is None:
await self._notify(handle, "ℹ️ There's no conversation to compact yet.")
return
# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# unbounded wait below. Informational, never an error — and plain
# text, because iMessage speech carries no markdown.
unsupported = compact_unsupported_backend(provider)
if unsupported:
await self._notify(
handle,
"ℹ️ This backend manages compaction automatically — it "
"summarizes the conversation on its own as context fills, "
"so manual /compact isn't needed (and isn't supported) here.",
)
return
await provider.compact()
await provider.wait_for_compaction()
await self._notify(handle, "🗜️ Context compacted.")
Expand Down
33 changes: 33 additions & 0 deletions src/kiro_crew/messaging/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,39 @@ def format_ttl(ttl_secs: int) -> str:
return f"{mins}m"


def compact_unsupported_backend(provider: Any) -> str | None:
"""Backend id when *provider* cannot serve a manual ``/compact``, else ``None``.

The channel half of the dashboard's manual-``/compact`` capability gate
(#7800): a backend outside ``ACP_BACKENDS_COMPACT`` treats the ``/compact``
prompt as ordinary text and never emits a compaction status, so dispatching
it strands ``wait_for_compaction()`` for its whole deadline. The capability
is read off the LIVE provider — ``manual_compact_unsupported_backend`` is
declared on the ``LLMProvider`` ABC with a ``None`` (supported) default per
harness-parity H14 — and only a non-empty ``str`` (the ABC's stated
contract) reads as a refusal, so a mocked or duck-typed provider's truthy
attribute never blocks a compaction.
"""
value = getattr(provider, "manual_compact_unsupported_backend", None)
if isinstance(value, str) and value:
return value
return None


def compact_unsupported_reply(backend: str) -> str:
"""Informational reply for a manual ``/compact`` on an unsupported *backend*.

Mirrors the dashboard's wording: the backend manages compaction
automatically (the same relationship the ``cc_managed`` decline encodes),
so the refusal is information, never an error.
"""
return (
f"ℹ️ The `{backend}` backend manages compaction automatically — it "
"summarizes the conversation on its own as context fills, so manual "
"`/compact` isn't needed (and isn't supported) here."
)


#: How much of a cron job's message body a list row shows.
_CRON_MESSAGE_PREVIEW_CHARS = 50
#: How much of a subagent's task a list row shows.
Expand Down
19 changes: 19 additions & 0 deletions src/kiro_crew/slack/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
)
from kiro_crew.messaging import auto_title, privacy_mode
from kiro_crew.messaging.commands import (
compact_unsupported_backend,
compact_unsupported_reply,
cron_command_reply,
spawn_command_reply,
task_command_reply,
Expand Down Expand Up @@ -2223,6 +2225,23 @@ async def _handle_compact_command(
)
return

# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# 120s wait below. Informational, never an error.
unsupported = compact_unsupported_backend(provider)
if unsupported:
await slack.post_message(channel, compact_unsupported_reply(unsupported), reply_ts)
sel().log_tool_invocation(
session_key=session_key,
source="slack",
tool_name="compact",
tool_kind="command",
outcome="auto_managed_backend",
metadata={"backend": unsupported},
)
return

_t0 = time.monotonic()

# --- Phase 1: Pre-compaction UI (cosmetic — log failures, don't abort) ---
Expand Down
18 changes: 18 additions & 0 deletions src/kiro_crew/teams/transport_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
from kiro_crew.messaging.attachments import cleanup as cleanup_attachments
from kiro_crew.messaging.commands import (
YOLO_PHRASING_MARKDOWN,
compact_unsupported_backend,
compact_unsupported_reply,
format_ttl,
parse_dashboard_ttl,
run_yolo_command,
Expand Down Expand Up @@ -1066,6 +1068,14 @@ async def _maybe_notice(self, inbound: "TeamsInbound", session_key: str, provide
assert self.client is not None
email = self._identity(inbound)
pct = self.sessions.check_context_usage(session_key, provider)
if pct >= self.cfg.teams.soft_threshold_pct:
# Capability gate (#8156): no forced compaction to run and the
# soft nudge's /compact advice cannot work — the backend compacts
# on its own as context fills.
unsupported = compact_unsupported_backend(provider)
if unsupported:
logger.debug("Teams: context notice skipped — %s compacts itself", unsupported)
return
if pct >= self.cfg.teams.hard_threshold_pct:
self._conv.clear_awaiting(email)
try:
Expand Down Expand Up @@ -1110,6 +1120,14 @@ async def _handle_compact(
if provider is None:
await self._reply(inbound, "ℹ️ There's no conversation to compact yet.")
return
# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# unbounded wait below. Informational, never an error.
unsupported = compact_unsupported_backend(provider)
if unsupported:
await self._reply(inbound, compact_unsupported_reply(unsupported))
return
await provider.compact()
await provider.wait_for_compaction()
await self._reply(inbound, "🗜️ Context compacted.")
Expand Down
16 changes: 16 additions & 0 deletions src/kiro_crew/telegram/transport_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
from kiro_crew.messaging.attachments import cleanup as cleanup_attachments
from kiro_crew.messaging.commands import (
YOLO_PHRASING_PLAIN,
compact_unsupported_backend,
compact_unsupported_reply,
cron_command_reply,
format_ttl,
lists_host_state,
Expand Down Expand Up @@ -2849,6 +2851,11 @@ async def _maybe_notice(
"""
pct = self.sessions.check_context_usage(session_key, provider)
soft_pct = self.cfg.telegram.soft_threshold_pct
if pct >= soft_pct and compact_unsupported_backend(provider):
# Capability gate (#8156): the nudge advises /compact, which this
# backend refuses — it compacts on its own as context fills, so
# there is nothing for the user to act on.
return
if pct >= soft_pct and not self._conv.is_awaiting(route):
self._conv.set_awaiting(route)
assert self.client is not None
Expand Down Expand Up @@ -2890,6 +2897,15 @@ async def _handle_compact(self, route: tuple[str, str], chat_id: int) -> None:
await self._reply(chat_id, "No active session to compact.", thread=thread)
return

# Capability gate (#8156, mirroring the dashboard's #7800 gate): a
# backend that cannot serve a manual /compact treats the prompt as
# ordinary text and never answers, so dispatching would strand the
# 120s wait below. Informational, never an error.
unsupported = compact_unsupported_backend(provider)
if unsupported:
await self._reply(chat_id, compact_unsupported_reply(unsupported), thread=thread)
return

status_id = await self._reply(chat_id, "🔄 Compacting context…", thread=thread)
result_text: str | None = None
try:
Expand Down
Loading
Loading