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
21 changes: 21 additions & 0 deletions bot/opencode_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,27 @@ async def get_child_sessions(self, parent_id: str) -> List[dict]:
all_sessions = await self.get_all_sessions()
return [s for s in all_sessions if s.get("parentID") == parent_id]

async def summarize_session(self, session_id: str, provider_id: str, model_id: str) -> bool:
"""Принудительно сжимает (суммаризирует) контекст сессии.

Использует POST /session/:sessionID/summarize. Операция может выполняться
долго (реальное AI-сжатие), поэтому используется увеличенный таймаут.
"""
url = f"{self.base_url}/session/{session_id}/summarize"
data = {"providerID": provider_id, "modelID": model_id}
long_timeout = ClientTimeout(total=600)
try:
async with self.session.post(url, json=data, timeout=long_timeout) as resp:
if resp.status in (200, 204):
logger.info(f"Session {session_id} summarized")
return True
text = await resp.text()
logger.error(f"Failed to summarize session: {resp.status} - {text}")
return False
except Exception as e:
logger.warning(f"Failed to summarize session {session_id}: {e}")
return False

async def delete_session(self, session_id: str) -> bool:
"""Удаляет сессию по ID."""
try:
Expand Down
73 changes: 73 additions & 0 deletions bot/reasoning_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Отслеживание зависания модели после reasoning.

Если reasoning.ended сработал, но за ним SILENCE_TIMEOUT минут не пришло
ни text.ended, ни step.ended, ни tool.called — отправляет промпт-напоминание.
"""
import asyncio
from typing import Optional, Callable, Awaitable

from logging_config import logger


SILENCE_TIMEOUT = 600 # 10 минут
NUDGE_PROMPT = "На чем остановился?"


class ReasoningTimeout:
"""Watchdog: если после reasoning тишина > SILENCE_TIMEOUT — нуджить модель."""

def __init__(self, send_prompt: Callable[[str, str], Awaitable[bool]]):
"""
Args:
send_prompt: функция (session_id, text) -> bool для отправки промпта.
"""
self.send_prompt = send_prompt

# session_id -> состояние
self._active: dict[str, dict] = {}
self._timers: dict[str, asyncio.Task] = {}

def reasoning_ended(self, session_id: str, user_id: int):
"""reasoning.ended — запускаем таймер."""
if session_id in self._active:
return # уже есть активный таймер

self._active[session_id] = {"user_id": user_id}
self._start_timer(session_id)

def activity(self, session_id: str):
"""Любая активность (text.ended, step.ended, tool.called) — сбрасываем таймер."""
self._cancel_timer(session_id)
self._active.pop(session_id, None)

def _start_timer(self, session_id: str):
self._cancel_timer(session_id)
task = asyncio.create_task(self._wait_and_nudge(session_id))
task.set_name(f"reasoning_timeout:{session_id[:8]}")
self._timers[session_id] = task

def _cancel_timer(self, session_id: str):
task = self._timers.pop(session_id, None)
if task and not task.done():
task.cancel()

async def _wait_and_nudge(self, session_id: str):
try:
await asyncio.sleep(SILENCE_TIMEOUT)
except asyncio.CancelledError:
return

state = self._active.pop(session_id, None)
self._timers.pop(session_id, None)
if not state:
return

user_id = state["user_id"]
logger.warning(
f"Reasoning timeout for session {session_id} (user {user_id}), sending nudge"
)
try:
await self.send_prompt(session_id, NUDGE_PROMPT)
except Exception as e:
logger.exception(f"Failed to send nudge prompt: {e}")
4 changes: 4 additions & 0 deletions bot/sse_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ async def _dispatch(self, raw: str):
except Exception as e:
logger.exception(f"SSE callback error for {event_type}: {e}")

# Log full event data for ended/failed events (text, reasoning, step)
if event_type.endswith(".ended") or event_type.endswith(".failed"):
logger.debug(f"SSE {event_type} data: {json.dumps(event_data, ensure_ascii=False)[:2000]}")

for cb in self._callbacks.get("*", []):
try:
await cb(event_type, event_data)
Expand Down
51 changes: 51 additions & 0 deletions bot/vk_longpoll.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from opencode_client import OpenCodeClient
from opencode_process import OpenCodeProcess
from session_manager import SessionManager
from reasoning_timeout import ReasoningTimeout
from sse_listener import SSEEventListener as SSEListener
from vk_client import VKClient

Expand Down Expand Up @@ -127,6 +128,9 @@ def __init__(
# Child session (subagent/subtask) tracking
self.parent_child_map: Dict[str, Dict[str, dict]] = {} # parent_id -> {child_id: {title, ...}}

# Watchdog: нуджить модель если после reasoning тишина > 10 мин
self.reasoning_timeout: Optional[ReasoningTimeout] = None

# Временные хранилища
self.waiting_for_answer: Dict = {} # user_id -> question_id или (peer_id, child_id) -> question_id
self.pending_permissions: Dict[str, Tuple[str, int, int]] = {}
Expand Down Expand Up @@ -165,6 +169,12 @@ async def _on_text_ended(self, event_type: str, data: dict):
text = data.get("text", "")
if not text.strip():
return
# Suppress internal processor error messages leaked to SSE
if text.startswith("[ERROR]"):
logger.debug(f"Suppressing processor error in text: {text[:100]}")
return
if self.reasoning_timeout:
self.reasoning_timeout.activity(session_id)
self._pending_texts.setdefault(session_id, []).append(text)

async def _on_reasoning_ended(self, event_type: str, data: dict):
Expand All @@ -178,6 +188,8 @@ async def _on_reasoning_ended(self, event_type: str, data: dict):
return
target = THINKING_PEER_ID if THINKING_PEER_ID else user_id
await self.vk.send_message(target, f"🧠:\n{text}")
if self.reasoning_timeout:
self.reasoning_timeout.reasoning_ended(session_id, user_id)

async def _on_tool_event(self, event_type: str, data: dict):
"""Обрабатывает события инструментов с детальным описанием"""
Expand All @@ -186,6 +198,9 @@ async def _on_tool_event(self, event_type: str, data: dict):
if not user_id:
return

if self.reasoning_timeout:
self.reasoning_timeout.activity(session_id)

tool_name = data.get("tool", "") or data.get("name", "")
target = THINKING_PEER_ID if THINKING_PEER_ID else user_id
action = event_type.split(".")[-1] if "." in event_type else "event"
Expand Down Expand Up @@ -227,6 +242,9 @@ async def _on_step_event(self, event_type: str, data: dict):
if not user_id:
return

if self.reasoning_timeout:
self.reasoning_timeout.activity(session_id)

step_type = event_type.split(".")[-1] if "." in event_type else "step"
target = THINKING_PEER_ID if THINKING_PEER_ID else user_id

Expand Down Expand Up @@ -768,6 +786,10 @@ async def _handle_message_new(self, event: list):
await self._handle_sessions_command(user_id)
return

if cmd == "/compact":
await self._handle_compact_command(user_id)
return

if cmd.startswith("/logs"):
await self._handle_logs_command(user_id)
return
Expand Down Expand Up @@ -1150,6 +1172,32 @@ async def _handle_sessions_command(self, user_id: int):
sessions_text += f"• `{sid}` (user={uid}) {marker}\n"
await self.vk.send_message(user_id, f"📋 **Список сессий**:\n\n{sessions_text}")

async def _handle_compact_command(self, user_id: int):
"""Обрабатывает команду /compact — принудительное сжатие контекста текущей сессии."""
session_id = self.session_mgr.sessions.get(user_id)
if not session_id:
await self.vk.send_message(user_id, "❌ Нет активной сессии")
return

# provider/model берём из текущей модели бота
model_alias = bot_config.DEFAULT_MODEL
model_info = bot_config.MODELS.get(model_alias, {})
model_id = model_info.get("model", model_alias)
provider_id = model_info.get("provider", "cli")

await self.vk.send_message(
user_id,
f"🔄 Сжимаю контекст сессии {session_id}...\nМожет занять до минуты.",
)
ok = await self.opencode_client.summarize_session(session_id, provider_id, model_id)
if ok:
await self.vk.send_message(user_id, "✅ Контекст сжат")
else:
await self.vk.send_message(
user_id,
"❌ Не удалось сжать контекст (см. логи). Возможно модель не поддерживает сжатие.",
)

async def _handle_logs_command(self, user_id: int):
"""Обрабатывает команду /logs"""
await self.vk.send_file(
Expand Down Expand Up @@ -1491,6 +1539,7 @@ async def _send_help(self, user_id: int):
/sysmon - Показать статус системы (GPU, RAM, процессы)
/logs - Отправить файл логов
/sessions - Показать список всех сессий
/compact - Принудительно сжать (суммаризировать) контекст текущей сессии
/newsession [path] - Создать новую сессию (очищает старые)
/n [path] - То же что /newsession
/newsession /path/to/project - Смена рабочей директории opencode serve
Expand All @@ -1516,6 +1565,8 @@ async def run(self):
self.opencode_client = OpenCodeClient()
await self.opencode_client.__aenter__()

self.reasoning_timeout = ReasoningTimeout(self.opencode_client.send_prompt)

self.sse_listener = SSEListener(OPENCODE_URL)
self._register_sse_callbacks()
await self.sse_listener.start()
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { findToolCallName } from "@opencode-ai/core/session/runner/publish-llm-event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
Expand Down Expand Up @@ -83,6 +84,7 @@ interface ProcessorContext extends Input {
currentTextID: string | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
v2AssistantMessageID: SessionMessage.ID | undefined
textBasedToolCall: boolean
}

type StreamEvent = LLMEvent
Expand Down Expand Up @@ -125,6 +127,7 @@ export const layer = Layer.effect(
currentTextID: undefined,
reasoningMap: {},
v2AssistantMessageID: undefined,
textBasedToolCall: false,
}
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
let aborted = false
Expand Down Expand Up @@ -247,6 +250,15 @@ export const layer = Layer.effect(

const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
if (!(reasoningID in ctx.reasoningMap)) return
// Some models emit XML-style tool calls (e.g. `<tool_call><function=grep>`) as
// reasoning text instead of using the structured tool_calls field. The tool is
// never executed, so replace the reasoning with an error and force another turn.
const reasoningToolCall = findToolCallName(ctx.reasoningMap[reasoningID].text)
if (reasoningToolCall) {
ctx.textBasedToolCall = true
ctx.reasoningMap[reasoningID].text =
`[ERROR] You outputted a tool call as text ("${reasoningToolCall}") in your reasoning instead of using the structured tool_calls field in the API response. The tool was NOT executed. You MUST retry using the proper tool_calls mechanism.`
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Ended, {
Expand Down Expand Up @@ -714,6 +726,10 @@ export const layer = Layer.effect(
}
}
ctx.assistantMessage.finish = value.reason
// A text-based tool call was detected and rewritten as an error. Keep the loop
// running (as if tools were called) so the model retries with a proper call
// instead of the turn silently ending.
if (ctx.textBasedToolCall && value.reason !== "tool-calls") ctx.assistantMessage.finish = "tool-calls"
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({
Expand Down Expand Up @@ -816,6 +832,17 @@ export const layer = Layer.effect(
},
{ text: ctx.currentText.text },
)).text
{
// Some models emit XML-style tool calls (e.g. `<function=grep>`) as plain
// text instead of using the structured tool_calls field. The tool is never
// executed, so replace the text with an error and force another turn.
const textToolCall = findToolCallName(ctx.currentText.text)
if (textToolCall) {
ctx.textBasedToolCall = true
ctx.currentText.text =
`[ERROR] You outputted a tool call as text ("${textToolCall}") instead of using the structured tool_calls field in the API response. The tool was NOT executed. You MUST retry using the proper tool_calls mechanism.`
}
}
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (mirrorAssistant) {
Expand Down
Loading