flowly-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
"""Agent loop: the core processing engine."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from flowly_code.bus.events import InboundMessage, OutboundMessage
|
|
15
|
+
from flowly_code.bus.queue import MessageBus
|
|
16
|
+
from flowly_code.providers.base import LLMProvider
|
|
17
|
+
from flowly_code.agent.context import ContextBuilder
|
|
18
|
+
from flowly_code.agent.tools.registry import ToolRegistry
|
|
19
|
+
from flowly_code.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
|
20
|
+
from flowly_code.agent.tools.web import WebSearchTool, WebFetchTool
|
|
21
|
+
from flowly_code.agent.tools.message import MessageTool
|
|
22
|
+
from flowly_code.agent.tools.screenshot import ScreenshotTool
|
|
23
|
+
from flowly_code.agent.tools.spawn import SpawnTool
|
|
24
|
+
from flowly_code.agent.tools.trello import TrelloTool
|
|
25
|
+
from flowly_code.agent.tools.docker import DockerTool
|
|
26
|
+
from flowly_code.agent.tools.system import SystemTool
|
|
27
|
+
from flowly_code.agent.subagent import SubagentManager
|
|
28
|
+
from flowly_code.session.manager import SessionManager
|
|
29
|
+
from flowly_code.compaction.service import CompactionService
|
|
30
|
+
from flowly_code.compaction.types import CompactionConfig, MemoryFlushConfig
|
|
31
|
+
from flowly_code.compaction.estimator import estimate_messages_tokens
|
|
32
|
+
from flowly_code.exec.types import ExecConfig
|
|
33
|
+
from flowly_code.config.schema import TrelloConfig, XConfig, DispatchConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentLoop:
|
|
37
|
+
"""
|
|
38
|
+
The agent loop is the core processing engine.
|
|
39
|
+
|
|
40
|
+
It:
|
|
41
|
+
1. Receives messages from the bus
|
|
42
|
+
2. Builds context with history, memory, skills
|
|
43
|
+
3. Calls the LLM
|
|
44
|
+
4. Executes tool calls
|
|
45
|
+
5. Sends responses back
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
bus: MessageBus,
|
|
51
|
+
provider: LLMProvider,
|
|
52
|
+
workspace: Path,
|
|
53
|
+
model: str | None = None,
|
|
54
|
+
action_temperature: float = 0.1,
|
|
55
|
+
action_tool_retries: int = 2,
|
|
56
|
+
max_iterations: int = 20,
|
|
57
|
+
brave_api_key: str | None = None,
|
|
58
|
+
context_messages: int = 100,
|
|
59
|
+
compaction_config: CompactionConfig | None = None,
|
|
60
|
+
exec_config: ExecConfig | None = None,
|
|
61
|
+
trello_config: TrelloConfig | None = None,
|
|
62
|
+
x_config: XConfig | None = None,
|
|
63
|
+
dispatch_config: DispatchConfig | None = None,
|
|
64
|
+
tools_config=None,
|
|
65
|
+
mcp_servers: dict | None = None,
|
|
66
|
+
persona: str = "default",
|
|
67
|
+
activity_bus: "ActivityBus | None" = None,
|
|
68
|
+
):
|
|
69
|
+
self.bus = bus
|
|
70
|
+
self.provider = provider
|
|
71
|
+
self.workspace = workspace
|
|
72
|
+
self.model = model or provider.get_default_model()
|
|
73
|
+
self.action_temperature = action_temperature
|
|
74
|
+
self.action_tool_retries = max(0, action_tool_retries)
|
|
75
|
+
self.max_iterations = max_iterations
|
|
76
|
+
self.brave_api_key = brave_api_key
|
|
77
|
+
self.context_messages = context_messages
|
|
78
|
+
self.dispatch_config = dispatch_config
|
|
79
|
+
self.tools_config = tools_config
|
|
80
|
+
|
|
81
|
+
# Activity streaming (real-time monitoring)
|
|
82
|
+
self.activity_bus = activity_bus
|
|
83
|
+
|
|
84
|
+
# MCP (Model Context Protocol) servers
|
|
85
|
+
self._mcp_servers = mcp_servers or {}
|
|
86
|
+
self._mcp_stack: AsyncExitStack | None = None
|
|
87
|
+
self._mcp_connected = False
|
|
88
|
+
|
|
89
|
+
self.context = ContextBuilder(workspace, persona=persona)
|
|
90
|
+
self.sessions = SessionManager(workspace)
|
|
91
|
+
self.tools = ToolRegistry()
|
|
92
|
+
self.subagents = SubagentManager(
|
|
93
|
+
provider=provider,
|
|
94
|
+
workspace=workspace,
|
|
95
|
+
bus=bus,
|
|
96
|
+
model=self.model,
|
|
97
|
+
brave_api_key=brave_api_key,
|
|
98
|
+
activity_bus=activity_bus,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Compaction service
|
|
102
|
+
self.compaction = CompactionService(
|
|
103
|
+
provider=provider,
|
|
104
|
+
model=self.model,
|
|
105
|
+
config=compaction_config,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Exec config
|
|
109
|
+
self.exec_config = exec_config or ExecConfig()
|
|
110
|
+
|
|
111
|
+
# Trello config
|
|
112
|
+
self.trello_config = trello_config
|
|
113
|
+
|
|
114
|
+
# X config
|
|
115
|
+
self.x_config = x_config
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
self._running = False
|
|
120
|
+
self._register_default_tools()
|
|
121
|
+
|
|
122
|
+
def _emit_activity(self, event_type: str, **kwargs: Any) -> None:
|
|
123
|
+
"""Emit an activity event if bus is available and has subscribers."""
|
|
124
|
+
if not self.activity_bus or not self.activity_bus.has_subscribers:
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
from flowly_code.activity.events import ActivityEvent
|
|
128
|
+
kwargs.setdefault("agent_name", self.context.persona)
|
|
129
|
+
self.activity_bus.emit(ActivityEvent(type=event_type, **kwargs))
|
|
130
|
+
except Exception:
|
|
131
|
+
pass # Never let activity tracking break the agent
|
|
132
|
+
|
|
133
|
+
def _fetch_project_paths(self) -> list[Path]:
|
|
134
|
+
"""Fetch Dispatch project directories synchronously (best-effort)."""
|
|
135
|
+
if not self.dispatch_config or not self.dispatch_config.enabled:
|
|
136
|
+
return []
|
|
137
|
+
try:
|
|
138
|
+
import httpx
|
|
139
|
+
port = self.dispatch_config.backend_port
|
|
140
|
+
resp = httpx.get(f"http://127.0.0.1:{port}/api/projects", timeout=5.0)
|
|
141
|
+
resp.raise_for_status()
|
|
142
|
+
projects = resp.json().get("data", [])
|
|
143
|
+
paths = []
|
|
144
|
+
for p in projects:
|
|
145
|
+
d = p.get("default_agent_working_dir", "")
|
|
146
|
+
if d:
|
|
147
|
+
paths.append(Path(d))
|
|
148
|
+
return paths
|
|
149
|
+
except Exception:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
def _register_default_tools(self) -> None:
|
|
153
|
+
"""Register the default set of tools."""
|
|
154
|
+
# Determine filesystem access mode
|
|
155
|
+
fs_access = "full"
|
|
156
|
+
fs_allowed: list[Path] | None = None
|
|
157
|
+
if self.tools_config and hasattr(self.tools_config, 'filesystem'):
|
|
158
|
+
fs_access = self.tools_config.filesystem.access
|
|
159
|
+
if fs_access == "projects":
|
|
160
|
+
fs_allowed = self._fetch_project_paths()
|
|
161
|
+
logger.info(f"Filesystem access: projects mode ({len(fs_allowed)} dirs)")
|
|
162
|
+
elif fs_access != "full":
|
|
163
|
+
logger.info(f"Filesystem access: {fs_access} mode")
|
|
164
|
+
|
|
165
|
+
# File tools
|
|
166
|
+
fs_kwargs = dict(workspace=self.workspace, access_mode=fs_access, allowed_paths=fs_allowed)
|
|
167
|
+
self.tools.register(ReadFileTool(**fs_kwargs))
|
|
168
|
+
self.tools.register(WriteFileTool(**fs_kwargs))
|
|
169
|
+
self.tools.register(EditFileTool(**fs_kwargs))
|
|
170
|
+
self.tools.register(ListDirTool(**fs_kwargs))
|
|
171
|
+
|
|
172
|
+
# Shell tool (secure)
|
|
173
|
+
from flowly_code.agent.tools.shell import SecureExecTool
|
|
174
|
+
self.tools.register(SecureExecTool(
|
|
175
|
+
config=self.exec_config,
|
|
176
|
+
working_dir=str(self.workspace),
|
|
177
|
+
))
|
|
178
|
+
|
|
179
|
+
# Web tools
|
|
180
|
+
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
|
181
|
+
self.tools.register(WebFetchTool())
|
|
182
|
+
|
|
183
|
+
# Message tool
|
|
184
|
+
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
|
185
|
+
self.tools.register(message_tool)
|
|
186
|
+
|
|
187
|
+
# Screenshot tool
|
|
188
|
+
self.tools.register(ScreenshotTool())
|
|
189
|
+
|
|
190
|
+
# Spawn tool (for subagents)
|
|
191
|
+
spawn_tool = SpawnTool(manager=self.subagents)
|
|
192
|
+
self.tools.register(spawn_tool)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Trello tool (if configured)
|
|
196
|
+
if self.trello_config and self.trello_config.api_key and self.trello_config.token:
|
|
197
|
+
self.tools.register(TrelloTool(
|
|
198
|
+
api_key=self.trello_config.api_key,
|
|
199
|
+
token=self.trello_config.token,
|
|
200
|
+
))
|
|
201
|
+
|
|
202
|
+
# X (Twitter) tool (if configured)
|
|
203
|
+
if self.x_config and (self.x_config.bearer_token or self.x_config.api_key):
|
|
204
|
+
from flowly_code.agent.tools.x import XTool
|
|
205
|
+
self.tools.register(XTool(
|
|
206
|
+
bearer_token=self.x_config.bearer_token,
|
|
207
|
+
api_key=self.x_config.api_key,
|
|
208
|
+
api_secret=self.x_config.api_secret,
|
|
209
|
+
access_token=self.x_config.access_token,
|
|
210
|
+
access_token_secret=self.x_config.access_token_secret,
|
|
211
|
+
))
|
|
212
|
+
|
|
213
|
+
# Docker tool (always available, will error if Docker not installed)
|
|
214
|
+
self.tools.register(DockerTool())
|
|
215
|
+
|
|
216
|
+
# System monitoring tool
|
|
217
|
+
self.tools.register(SystemTool())
|
|
218
|
+
|
|
219
|
+
# Dispatch App tools (if configured)
|
|
220
|
+
if self.dispatch_config and self.dispatch_config.enabled:
|
|
221
|
+
from flowly_code.agent.tools.dispatch import (
|
|
222
|
+
DispatchListProjectsTool,
|
|
223
|
+
DispatchGetProjectTool,
|
|
224
|
+
DispatchListTasksTool,
|
|
225
|
+
DispatchCreateTaskTool,
|
|
226
|
+
DispatchUpdateTaskTool,
|
|
227
|
+
DispatchDeleteTaskTool,
|
|
228
|
+
DispatchGetTaskTool,
|
|
229
|
+
DispatchRalphStatusTool,
|
|
230
|
+
DispatchStartRalphTool,
|
|
231
|
+
DispatchListTaskAttemptsTool,
|
|
232
|
+
DispatchStartRalphSessionTool,
|
|
233
|
+
DispatchStopRalphSessionTool,
|
|
234
|
+
DispatchKanbanSummaryTool,
|
|
235
|
+
DispatchGetRalphSessionTool,
|
|
236
|
+
DispatchGetRalphPrdTool,
|
|
237
|
+
)
|
|
238
|
+
port = self.dispatch_config.backend_port
|
|
239
|
+
self.tools.register(DispatchListProjectsTool(port=port))
|
|
240
|
+
self.tools.register(DispatchGetProjectTool(port=port))
|
|
241
|
+
self.tools.register(DispatchListTasksTool(port=port))
|
|
242
|
+
self.tools.register(DispatchCreateTaskTool(port=port))
|
|
243
|
+
self.tools.register(DispatchUpdateTaskTool(port=port))
|
|
244
|
+
self.tools.register(DispatchDeleteTaskTool(port=port))
|
|
245
|
+
self.tools.register(DispatchGetTaskTool(port=port))
|
|
246
|
+
self.tools.register(DispatchRalphStatusTool(port=port))
|
|
247
|
+
self.tools.register(DispatchStartRalphTool(port=port))
|
|
248
|
+
self.tools.register(DispatchListTaskAttemptsTool(port=port))
|
|
249
|
+
self.tools.register(DispatchStartRalphSessionTool(port=port))
|
|
250
|
+
self.tools.register(DispatchStopRalphSessionTool(port=port))
|
|
251
|
+
self.tools.register(DispatchKanbanSummaryTool(port=port))
|
|
252
|
+
self.tools.register(DispatchGetRalphSessionTool(port=port))
|
|
253
|
+
self.tools.register(DispatchGetRalphPrdTool(port=port))
|
|
254
|
+
|
|
255
|
+
async def _connect_mcp(self) -> None:
|
|
256
|
+
"""Connect to configured MCP servers (one-time, lazy)."""
|
|
257
|
+
if self._mcp_connected or not self._mcp_servers:
|
|
258
|
+
return
|
|
259
|
+
self._mcp_connected = True
|
|
260
|
+
from flowly_code.agent.tools.mcp import connect_mcp_servers
|
|
261
|
+
|
|
262
|
+
self._mcp_stack = AsyncExitStack()
|
|
263
|
+
await self._mcp_stack.__aenter__()
|
|
264
|
+
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
|
|
265
|
+
|
|
266
|
+
async def close_mcp(self) -> None:
|
|
267
|
+
"""Cleanup MCP connections."""
|
|
268
|
+
if self._mcp_stack:
|
|
269
|
+
await self._mcp_stack.aclose()
|
|
270
|
+
self._mcp_stack = None
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _tool_hint(tool_calls: list) -> str:
|
|
274
|
+
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
|
|
275
|
+
def _fmt(tc):
|
|
276
|
+
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
|
|
277
|
+
if not isinstance(val, str):
|
|
278
|
+
return tc.name
|
|
279
|
+
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
|
280
|
+
return ", ".join(_fmt(tc) for tc in tool_calls)
|
|
281
|
+
|
|
282
|
+
async def run(self) -> None:
|
|
283
|
+
"""Run the agent loop, processing messages from the bus."""
|
|
284
|
+
self._running = True
|
|
285
|
+
await self._connect_mcp()
|
|
286
|
+
logger.info("Agent loop started")
|
|
287
|
+
|
|
288
|
+
while self._running:
|
|
289
|
+
try:
|
|
290
|
+
# Wait for next message
|
|
291
|
+
first_msg = await asyncio.wait_for(
|
|
292
|
+
self.bus.consume_inbound(),
|
|
293
|
+
timeout=1.0
|
|
294
|
+
)
|
|
295
|
+
batch, dropped = self._coalesce_inbound_batch(first_msg)
|
|
296
|
+
if dropped:
|
|
297
|
+
logger.warning(f"Inbound coalescing dropped {dropped} stale message(s)")
|
|
298
|
+
|
|
299
|
+
# Process coalesced batch
|
|
300
|
+
for msg in batch:
|
|
301
|
+
try:
|
|
302
|
+
response = await self._process_message(msg)
|
|
303
|
+
if response:
|
|
304
|
+
await self.bus.publish_outbound(response)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logger.error(f"Error processing message: {e}")
|
|
307
|
+
# Send error response
|
|
308
|
+
await self.bus.publish_outbound(OutboundMessage(
|
|
309
|
+
channel=msg.channel,
|
|
310
|
+
chat_id=msg.chat_id,
|
|
311
|
+
content=f"Sorry, I encountered an error: {str(e)}"
|
|
312
|
+
))
|
|
313
|
+
except asyncio.TimeoutError:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
def stop(self) -> None:
|
|
317
|
+
"""Stop the agent loop."""
|
|
318
|
+
self._running = False
|
|
319
|
+
logger.info("Agent loop stopping")
|
|
320
|
+
# Schedule MCP cleanup (fire-and-forget since stop() is sync)
|
|
321
|
+
if self._mcp_stack:
|
|
322
|
+
try:
|
|
323
|
+
loop = asyncio.get_event_loop()
|
|
324
|
+
if loop.is_running():
|
|
325
|
+
loop.create_task(self.close_mcp())
|
|
326
|
+
except RuntimeError:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
def _extract_action_intent_text(self, content: str) -> str:
|
|
330
|
+
"""Extract the user utterance for intent detection."""
|
|
331
|
+
return content.lower()
|
|
332
|
+
|
|
333
|
+
def _is_action_turn(self, channel: str, content: str) -> bool:
|
|
334
|
+
"""Detect whether this turn is an action request that should execute tools strictly."""
|
|
335
|
+
intent_text = self._extract_action_intent_text(content)
|
|
336
|
+
action_patterns = (
|
|
337
|
+
# Retry
|
|
338
|
+
r"\btry\s+again\b",
|
|
339
|
+
r"\bretry\b",
|
|
340
|
+
r"\btekrar\s+dene\b",
|
|
341
|
+
r"\btekrar\s+b[iı]\s+dene\b",
|
|
342
|
+
r"\btekrar\s+bir\s+dene\b",
|
|
343
|
+
r"\btekrar\s+dener\s+m[ıi]s[ıi]n\b",
|
|
344
|
+
r"\btekrar\b.*\bden\w+\b",
|
|
345
|
+
r"\byeniden\s+dene\b",
|
|
346
|
+
r"\bbir\s+daha\s+dene\b",
|
|
347
|
+
# Send / share
|
|
348
|
+
r"\bsend\b",
|
|
349
|
+
r"\bshare\b",
|
|
350
|
+
r"\bg[öo]nder\b",
|
|
351
|
+
r"\bpayla[şs]\b",
|
|
352
|
+
# Screenshot
|
|
353
|
+
r"\bscreenshot\b",
|
|
354
|
+
r"\bss\b",
|
|
355
|
+
r"\bekran\s+g[öo]r[üu]nt[üu]s[üu]\b",
|
|
356
|
+
# Generic
|
|
357
|
+
r"\brun\s+tool\b",
|
|
358
|
+
r"\bexecute\b",
|
|
359
|
+
)
|
|
360
|
+
return any(re.search(pattern, intent_text) for pattern in action_patterns)
|
|
361
|
+
|
|
362
|
+
def _is_retry_action_followup(self, content: str) -> bool:
|
|
363
|
+
"""Detect short follow-up prompts that usually mean 'retry previous action'."""
|
|
364
|
+
intent_text = self._extract_action_intent_text(content)
|
|
365
|
+
retry_patterns = (
|
|
366
|
+
r"\btry\s+again\b",
|
|
367
|
+
r"\bretry\b",
|
|
368
|
+
r"\bdo\s+it\s+again\b",
|
|
369
|
+
r"\bone\s+more\s+time\b",
|
|
370
|
+
r"\btekrar\s+dene\b",
|
|
371
|
+
r"\btekrar\s+b[iı]\s+dene\b",
|
|
372
|
+
r"\btekrar\s+bir\s+dene\b",
|
|
373
|
+
r"\btekrar\s+dener\s+m[ıi]s[ıi]n\b",
|
|
374
|
+
r"\btekrar\b.*\bden\w+\b",
|
|
375
|
+
r"\byeniden\s+dene\b",
|
|
376
|
+
r"\bbir\s+daha\s+dene\b",
|
|
377
|
+
)
|
|
378
|
+
return any(re.search(pattern, intent_text) for pattern in retry_patterns)
|
|
379
|
+
|
|
380
|
+
def _is_cancel_action_followup(self, content: str) -> bool:
|
|
381
|
+
"""Detect explicit cancellation for pending actions."""
|
|
382
|
+
intent_text = self._extract_action_intent_text(content)
|
|
383
|
+
cancel_patterns = (
|
|
384
|
+
r"\bcancel\b",
|
|
385
|
+
r"\bstop\b",
|
|
386
|
+
r"\bforget\s+it\b",
|
|
387
|
+
r"\bnever\s*mind\b",
|
|
388
|
+
r"\babort\b",
|
|
389
|
+
r"\bvazge[cç]\b",
|
|
390
|
+
r"\biptal\b",
|
|
391
|
+
r"\bbo[sş]ver\b",
|
|
392
|
+
)
|
|
393
|
+
return any(re.search(pattern, intent_text) for pattern in cancel_patterns)
|
|
394
|
+
|
|
395
|
+
def _consume_pending_action_lock(self, session: Any, content: str) -> bool:
|
|
396
|
+
"""
|
|
397
|
+
Consume a pending-action lock set by a previous failed action turn.
|
|
398
|
+
|
|
399
|
+
If active, force this turn into action mode unless user explicitly cancels.
|
|
400
|
+
"""
|
|
401
|
+
pending = session.metadata.get("pending_action_lock")
|
|
402
|
+
if not isinstance(pending, dict):
|
|
403
|
+
return False
|
|
404
|
+
if not pending.get("active"):
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
remaining = int(pending.get("remaining_turns", 0) or 0)
|
|
408
|
+
if remaining <= 0:
|
|
409
|
+
session.metadata.pop("pending_action_lock", None)
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
if self._is_cancel_action_followup(content):
|
|
413
|
+
session.metadata.pop("pending_action_lock", None)
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
pending["remaining_turns"] = remaining - 1
|
|
417
|
+
pending["last_consumed_at"] = datetime.now().isoformat()
|
|
418
|
+
session.metadata["pending_action_lock"] = pending
|
|
419
|
+
return True
|
|
420
|
+
|
|
421
|
+
def _set_pending_action_lock(self, session: Any, request_text: str) -> None:
|
|
422
|
+
"""Arm pending-action lock so next follow-up is forced into action mode."""
|
|
423
|
+
session.metadata["pending_action_lock"] = {
|
|
424
|
+
"active": True,
|
|
425
|
+
"remaining_turns": 2,
|
|
426
|
+
"request": request_text[:300],
|
|
427
|
+
"set_at": datetime.now().isoformat(),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
def _clear_pending_action_lock(self, session: Any) -> None:
|
|
431
|
+
"""Clear pending-action lock after successful action execution."""
|
|
432
|
+
session.metadata.pop("pending_action_lock", None)
|
|
433
|
+
|
|
434
|
+
def _should_promote_retry_to_action(
|
|
435
|
+
self,
|
|
436
|
+
content: str,
|
|
437
|
+
history: list[dict[str, Any]],
|
|
438
|
+
) -> bool:
|
|
439
|
+
"""Promote retry follow-ups to action turns when recent context indicates pending action."""
|
|
440
|
+
if not self._is_retry_action_followup(content):
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
# Strong default: retry follow-ups are treated as action intents.
|
|
444
|
+
if history:
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
recent_messages = history[-6:]
|
|
448
|
+
recent_text = " ".join(
|
|
449
|
+
str(msg.get("content", "")).lower()
|
|
450
|
+
for msg in recent_messages
|
|
451
|
+
if isinstance(msg, dict)
|
|
452
|
+
)
|
|
453
|
+
retry_context_markers = (
|
|
454
|
+
"tool call could not be verified",
|
|
455
|
+
"tool calls failed",
|
|
456
|
+
"no action was taken",
|
|
457
|
+
)
|
|
458
|
+
if any(marker in recent_text for marker in retry_context_markers):
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
# If recent user messages were action-like, treat retry as action.
|
|
462
|
+
for msg in reversed(recent_messages):
|
|
463
|
+
if not isinstance(msg, dict):
|
|
464
|
+
continue
|
|
465
|
+
if msg.get("role") != "user":
|
|
466
|
+
continue
|
|
467
|
+
text = str(msg.get("content", ""))
|
|
468
|
+
if text and self._is_action_turn("", text):
|
|
469
|
+
return True
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
def _contains_unverified_completion_claim(self, text: str) -> bool:
|
|
473
|
+
"""Detect response phrases that claim completion without tool evidence."""
|
|
474
|
+
lowered = (text or "").lower()
|
|
475
|
+
claim_patterns = (
|
|
476
|
+
r"\byapt[ıi]m\b",
|
|
477
|
+
r"\bg[öo]nderdim\b",
|
|
478
|
+
r"\bald[ıi]m\b",
|
|
479
|
+
r"\ba[cç]t[ıi]m\b",
|
|
480
|
+
r"\bkapatt[ıi]m\b",
|
|
481
|
+
r"\btamamlad[ıi]m\b",
|
|
482
|
+
r"\bi did\b",
|
|
483
|
+
r"\bi sent\b",
|
|
484
|
+
r"\bi took\b",
|
|
485
|
+
r"\bi opened\b",
|
|
486
|
+
r"\bi closed\b",
|
|
487
|
+
r"\bdone\b",
|
|
488
|
+
r"\bcompleted\b",
|
|
489
|
+
r"\bfinished\b",
|
|
490
|
+
)
|
|
491
|
+
return any(re.search(pattern, lowered) for pattern in claim_patterns)
|
|
492
|
+
|
|
493
|
+
# Hardcoded fallback messages that should be replaced by model-generated summaries.
|
|
494
|
+
_HARDCODED_FALLBACKS = frozenset({
|
|
495
|
+
"Tool calls failed, no action was taken.",
|
|
496
|
+
"Tool call could not be verified, no action was taken.",
|
|
497
|
+
"No safe tool could be executed for the live call.",
|
|
498
|
+
"Action executed.",
|
|
499
|
+
"Action completed but no response could be generated.",
|
|
500
|
+
"No tool was executed, no action was taken.",
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
def _is_hardcoded_fallback(self, content: str) -> bool:
|
|
504
|
+
"""Check if final_content is a hardcoded fallback rather than model output."""
|
|
505
|
+
if content in self._HARDCODED_FALLBACKS:
|
|
506
|
+
return True
|
|
507
|
+
if content.startswith("Actions completed (") and "tools executed" in content:
|
|
508
|
+
return True
|
|
509
|
+
if content.startswith("✓ Action completed"):
|
|
510
|
+
return True
|
|
511
|
+
if content.startswith("Action completed.\n"):
|
|
512
|
+
return True
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
async def _request_summary_turn(
|
|
516
|
+
self, messages: list[dict], tool_results: list[dict]
|
|
517
|
+
) -> str | None:
|
|
518
|
+
"""Ask the model to summarize tool results in natural language.
|
|
519
|
+
|
|
520
|
+
When tool calls complete but the loop exits with a hardcoded fallback,
|
|
521
|
+
this gives the model a chance to explain what happened to the user.
|
|
522
|
+
"""
|
|
523
|
+
summary_prompt = (
|
|
524
|
+
"The tool calls above have completed. "
|
|
525
|
+
"Summarize what happened to the user in a natural, concise way. "
|
|
526
|
+
"If there were errors, explain what went wrong clearly."
|
|
527
|
+
)
|
|
528
|
+
messages_copy = list(messages)
|
|
529
|
+
messages_copy.append({"role": "user", "content": summary_prompt})
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
response = await self.provider.chat(
|
|
533
|
+
messages=messages_copy,
|
|
534
|
+
tools=[],
|
|
535
|
+
model=self.model,
|
|
536
|
+
temperature=0.7,
|
|
537
|
+
)
|
|
538
|
+
if response.content and response.content.strip():
|
|
539
|
+
return response.content.strip()
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.warning(f"Summary turn failed, keeping fallback: {e}")
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
def _is_strict_live_call_action_intent(self, content: str) -> bool:
|
|
545
|
+
"""
|
|
546
|
+
Detect high-confidence action intents in an active call turn.
|
|
547
|
+
|
|
548
|
+
This avoids forcing tools for regular chat utterances.
|
|
549
|
+
"""
|
|
550
|
+
intent_text = self._extract_action_intent_text(content)
|
|
551
|
+
strict_patterns = (
|
|
552
|
+
r"\bg[öo]nder\b",
|
|
553
|
+
r"\bsend\b",
|
|
554
|
+
r"\bekran\s+g[öo]r[üu]nt[üu]s[üu]\b",
|
|
555
|
+
r"\bscreenshot\b",
|
|
556
|
+
)
|
|
557
|
+
return any(re.search(pattern, intent_text) for pattern in strict_patterns)
|
|
558
|
+
|
|
559
|
+
def _is_live_call_turn(self, content: str) -> bool:
|
|
560
|
+
"""Detect active call orchestration prompts (disabled - voice removed)."""
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
def _apply_turn_tool_policy(
|
|
564
|
+
self,
|
|
565
|
+
tool_defs: list[dict[str, Any]],
|
|
566
|
+
live_call_turn: bool,
|
|
567
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
568
|
+
"""Apply per-turn tool constraints for safety and predictability."""
|
|
569
|
+
# Voice/call handling removed for Dispatch integration
|
|
570
|
+
return tool_defs, []
|
|
571
|
+
|
|
572
|
+
def _is_live_call_tool_allowed(self, tool_name: str, tool_args: dict[str, Any]) -> bool:
|
|
573
|
+
"""Final runtime guard for live-call tool execution (disabled)."""
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
def _coalesce_inbound_batch(self, first_msg: InboundMessage) -> tuple[list[InboundMessage], int]:
|
|
577
|
+
"""
|
|
578
|
+
Collect bursty inbound traffic without dropping user messages.
|
|
579
|
+
|
|
580
|
+
Queue-All policy: preserve full ordering and keep every message.
|
|
581
|
+
"""
|
|
582
|
+
batch = [first_msg]
|
|
583
|
+
|
|
584
|
+
while True:
|
|
585
|
+
try:
|
|
586
|
+
batch.append(self.bus.inbound.get_nowait())
|
|
587
|
+
except asyncio.QueueEmpty:
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
return batch, 0
|
|
591
|
+
|
|
592
|
+
async def _run_llm_tool_loop(
|
|
593
|
+
self,
|
|
594
|
+
messages: list[dict[str, Any]],
|
|
595
|
+
action_turn: bool,
|
|
596
|
+
live_call_turn: bool = False,
|
|
597
|
+
turn_content: str = "",
|
|
598
|
+
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
|
599
|
+
) -> tuple[str, list[dict[str, Any]], list[str]]:
|
|
600
|
+
"""
|
|
601
|
+
Run iterative LLM + tool execution loop until final response.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
(final_content, accumulated_tool_results, executed_tool_names)
|
|
605
|
+
"""
|
|
606
|
+
iteration = 0
|
|
607
|
+
final_content: str | None = None
|
|
608
|
+
accumulated_tool_results: list[dict[str, Any]] = []
|
|
609
|
+
executed_tool_names: list[str] = []
|
|
610
|
+
blocked_tools: list[str] = []
|
|
611
|
+
tools_were_used = False
|
|
612
|
+
successful_tools_were_used = False
|
|
613
|
+
no_tool_retry_count = 0
|
|
614
|
+
forced_tool_retry = False
|
|
615
|
+
strict_live_call_action = live_call_turn and self._is_strict_live_call_action_intent(turn_content)
|
|
616
|
+
enforce_action_tools = action_turn and (not live_call_turn or strict_live_call_action)
|
|
617
|
+
|
|
618
|
+
selected_model = self.model
|
|
619
|
+
selected_temperature = self.action_temperature if action_turn else 0.7
|
|
620
|
+
max_turn_iterations = self.max_iterations
|
|
621
|
+
if live_call_turn and not enforce_action_tools:
|
|
622
|
+
max_turn_iterations = min(max_turn_iterations, 3)
|
|
623
|
+
|
|
624
|
+
while iteration < max_turn_iterations:
|
|
625
|
+
iteration += 1
|
|
626
|
+
self._emit_activity("iteration_start", iteration=iteration)
|
|
627
|
+
|
|
628
|
+
tool_defs, policy_blocked_tools = self._apply_turn_tool_policy(
|
|
629
|
+
self.tools.get_definitions(),
|
|
630
|
+
live_call_turn=live_call_turn,
|
|
631
|
+
)
|
|
632
|
+
if policy_blocked_tools:
|
|
633
|
+
blocked_tools.extend(policy_blocked_tools)
|
|
634
|
+
tool_choice = (
|
|
635
|
+
"required"
|
|
636
|
+
if ((enforce_action_tools or forced_tool_retry) and not successful_tools_were_used)
|
|
637
|
+
else "auto"
|
|
638
|
+
)
|
|
639
|
+
logger.info(
|
|
640
|
+
"LLM request telemetry: "
|
|
641
|
+
f"model={selected_model}, tool_choice={tool_choice}, tool_count={len(tool_defs)}, "
|
|
642
|
+
f"action_turn={action_turn}, live_call_turn={live_call_turn}, "
|
|
643
|
+
f"blocked_tools={sorted(set(blocked_tools))}, "
|
|
644
|
+
f"iteration={iteration}/{max_turn_iterations}"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
self._emit_activity("llm_start", iteration=iteration)
|
|
648
|
+
response = await self.provider.chat(
|
|
649
|
+
messages=messages,
|
|
650
|
+
tools=tool_defs,
|
|
651
|
+
model=selected_model,
|
|
652
|
+
temperature=selected_temperature,
|
|
653
|
+
tool_choice=tool_choice,
|
|
654
|
+
)
|
|
655
|
+
self._emit_activity("llm_end", iteration=iteration)
|
|
656
|
+
|
|
657
|
+
if response.content and response.content.startswith("Error") and tool_choice == "required":
|
|
658
|
+
logger.warning(f"tool_choice=required failed, retrying with auto: {response.content[:120]}")
|
|
659
|
+
response = await self.provider.chat(
|
|
660
|
+
messages=messages,
|
|
661
|
+
tools=tool_defs,
|
|
662
|
+
model=selected_model,
|
|
663
|
+
temperature=selected_temperature,
|
|
664
|
+
tool_choice="auto",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if response.content and response.content.startswith("Error calling LLM:"):
|
|
668
|
+
lowered_error = response.content.lower()
|
|
669
|
+
schema_rejected = (
|
|
670
|
+
"input_schema does not support oneof" in lowered_error
|
|
671
|
+
or "input_schema does not support allof" in lowered_error
|
|
672
|
+
or "input_schema does not support anyof" in lowered_error
|
|
673
|
+
)
|
|
674
|
+
if schema_rejected:
|
|
675
|
+
logger.error("Provider rejected tool schema; aborting turn without additional retries.")
|
|
676
|
+
final_content = (
|
|
677
|
+
"Tool schema was rejected by the model provider. "
|
|
678
|
+
"No action was taken."
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
logger.error("LLM call failed after fallback; aborting turn without additional retries.")
|
|
682
|
+
final_content = (
|
|
683
|
+
"Could not get a valid response from the model provider. "
|
|
684
|
+
"No action was taken."
|
|
685
|
+
)
|
|
686
|
+
break
|
|
687
|
+
|
|
688
|
+
logger.info(
|
|
689
|
+
"LLM response telemetry: "
|
|
690
|
+
f"has_tool_calls={response.has_tool_calls}, content_len={len(response.content or '')}, "
|
|
691
|
+
f"action_turn={action_turn}, live_call_turn={live_call_turn}, iteration={iteration}"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if response.has_tool_calls:
|
|
695
|
+
# Emit progress hint to caller
|
|
696
|
+
if on_progress:
|
|
697
|
+
hint = self._tool_hint(response.tool_calls)
|
|
698
|
+
await on_progress(hint)
|
|
699
|
+
|
|
700
|
+
tool_call_dicts = [
|
|
701
|
+
{
|
|
702
|
+
"id": tc.id,
|
|
703
|
+
"type": "function",
|
|
704
|
+
"function": {
|
|
705
|
+
"name": tc.name,
|
|
706
|
+
"arguments": json.dumps(tc.arguments),
|
|
707
|
+
},
|
|
708
|
+
}
|
|
709
|
+
for tc in response.tool_calls
|
|
710
|
+
]
|
|
711
|
+
|
|
712
|
+
assistant_content = None
|
|
713
|
+
if response.content:
|
|
714
|
+
content_lower = response.content.lower()
|
|
715
|
+
hallucination_phrases = [
|
|
716
|
+
"i did", "i sent", "i took", "i opened", "i closed",
|
|
717
|
+
"done", "completed", "finished",
|
|
718
|
+
"yaptım", "gönderdim", "aldım", "açtım", "kapattım", "tamamlandı",
|
|
719
|
+
]
|
|
720
|
+
if not any(phrase in content_lower for phrase in hallucination_phrases):
|
|
721
|
+
assistant_content = response.content
|
|
722
|
+
|
|
723
|
+
messages = self.context.add_assistant_message(
|
|
724
|
+
messages, assistant_content, tool_call_dicts
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
turn_tools: list[str] = []
|
|
728
|
+
terminal_action_executed = False
|
|
729
|
+
turn_success_count = 0
|
|
730
|
+
for tool_call in response.tool_calls:
|
|
731
|
+
turn_tools.append(tool_call.name)
|
|
732
|
+
executed_tool_names.append(tool_call.name)
|
|
733
|
+
args_str = json.dumps(tool_call.arguments)
|
|
734
|
+
logger.info(f"Executing tool: {tool_call.name}({args_str[:160]}...)")
|
|
735
|
+
|
|
736
|
+
if live_call_turn and not self._is_live_call_tool_allowed(
|
|
737
|
+
tool_call.name,
|
|
738
|
+
tool_call.arguments,
|
|
739
|
+
):
|
|
740
|
+
blocked_tools.append(tool_call.name)
|
|
741
|
+
result = (
|
|
742
|
+
f"Error: Tool '{tool_call.name}' was blocked by the "
|
|
743
|
+
"live-call security policy."
|
|
744
|
+
)
|
|
745
|
+
logger.error(
|
|
746
|
+
f"Live call blocked risky tool: {tool_call.name} args={args_str[:160]}"
|
|
747
|
+
)
|
|
748
|
+
accumulated_tool_results.append({
|
|
749
|
+
"tool": tool_call.name,
|
|
750
|
+
"success": False,
|
|
751
|
+
"result": result,
|
|
752
|
+
})
|
|
753
|
+
messages = self.context.add_tool_result(
|
|
754
|
+
messages, tool_call.id, tool_call.name, result
|
|
755
|
+
)
|
|
756
|
+
continue
|
|
757
|
+
|
|
758
|
+
self._emit_activity(
|
|
759
|
+
"tool_start",
|
|
760
|
+
iteration=iteration,
|
|
761
|
+
tool_name=tool_call.name,
|
|
762
|
+
tool_args_preview=args_str[:100],
|
|
763
|
+
)
|
|
764
|
+
try:
|
|
765
|
+
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
|
766
|
+
accumulated_tool_results.append({
|
|
767
|
+
"tool": tool_call.name,
|
|
768
|
+
"success": not result.startswith("Error"),
|
|
769
|
+
"result": result[:500] if len(result) > 500 else result,
|
|
770
|
+
})
|
|
771
|
+
except Exception as e:
|
|
772
|
+
result = f"Error executing {tool_call.name}: {str(e)}"
|
|
773
|
+
logger.error(result)
|
|
774
|
+
accumulated_tool_results.append({
|
|
775
|
+
"tool": tool_call.name,
|
|
776
|
+
"success": False,
|
|
777
|
+
"result": result,
|
|
778
|
+
})
|
|
779
|
+
else:
|
|
780
|
+
if not result.startswith("Error"):
|
|
781
|
+
turn_success_count += 1
|
|
782
|
+
logger.info(
|
|
783
|
+
f"Tool success: {tool_call.name} result={result[:180]}"
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
logger.warning(
|
|
787
|
+
f"Tool failed: {tool_call.name} result={result[:220]}"
|
|
788
|
+
)
|
|
789
|
+
self._emit_activity(
|
|
790
|
+
"tool_end",
|
|
791
|
+
iteration=iteration,
|
|
792
|
+
tool_name=tool_call.name,
|
|
793
|
+
success=not result.startswith("Error"),
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
messages = self.context.add_tool_result(
|
|
797
|
+
messages, tool_call.id, tool_call.name, result
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
# In strict action turns, stop as soon as a terminal action succeeds.
|
|
801
|
+
# (voice_call and cron tools removed for Dispatch integration)
|
|
802
|
+
|
|
803
|
+
if terminal_action_executed:
|
|
804
|
+
logger.info(
|
|
805
|
+
"Action turn terminal tool executed; skipping remaining tool calls in this batch."
|
|
806
|
+
)
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
logger.info(f"Tool execution telemetry: executed_tools={turn_tools}")
|
|
810
|
+
tools_were_used = True
|
|
811
|
+
if turn_success_count > 0:
|
|
812
|
+
successful_tools_were_used = True
|
|
813
|
+
forced_tool_retry = False
|
|
814
|
+
|
|
815
|
+
if terminal_action_executed:
|
|
816
|
+
successful = [t for t in accumulated_tool_results if t.get("success")]
|
|
817
|
+
if successful:
|
|
818
|
+
last_ok = successful[-1]
|
|
819
|
+
final_content = (
|
|
820
|
+
"Action completed.\n"
|
|
821
|
+
f"{last_ok['tool']}: {last_ok['result']}"
|
|
822
|
+
)
|
|
823
|
+
else:
|
|
824
|
+
final_content = "Action executed."
|
|
825
|
+
break
|
|
826
|
+
|
|
827
|
+
if live_call_turn and not enforce_action_tools:
|
|
828
|
+
successful = [t for t in accumulated_tool_results if t.get("success")]
|
|
829
|
+
if successful:
|
|
830
|
+
last_ok = successful[-1]
|
|
831
|
+
final_content = (
|
|
832
|
+
response.content.strip()
|
|
833
|
+
if response.content and response.content.strip()
|
|
834
|
+
else f"Action completed: {last_ok['tool']}"
|
|
835
|
+
)
|
|
836
|
+
else:
|
|
837
|
+
final_content = "No safe tool could be executed for the live call."
|
|
838
|
+
break
|
|
839
|
+
|
|
840
|
+
if enforce_action_tools and turn_success_count == 0:
|
|
841
|
+
if no_tool_retry_count < self.action_tool_retries:
|
|
842
|
+
no_tool_retry_count += 1
|
|
843
|
+
logger.warning(
|
|
844
|
+
"Action turn tool calls all failed; retrying with corrective instruction "
|
|
845
|
+
f"({no_tool_retry_count}/{self.action_tool_retries})"
|
|
846
|
+
)
|
|
847
|
+
messages.append({
|
|
848
|
+
"role": "user",
|
|
849
|
+
"content": (
|
|
850
|
+
"The previous tool call failed. "
|
|
851
|
+
"Retry the relevant tool with correct parameters. "
|
|
852
|
+
"If it fails, give a clear error — do not call unrelated tools."
|
|
853
|
+
),
|
|
854
|
+
})
|
|
855
|
+
continue
|
|
856
|
+
final_content = "Tool calls failed, no action was taken."
|
|
857
|
+
break
|
|
858
|
+
|
|
859
|
+
continue
|
|
860
|
+
|
|
861
|
+
# Provider/model may hallucinate completion without emitting tool calls.
|
|
862
|
+
# OpenClaw-style guard: force a corrective tool-only retry before responding.
|
|
863
|
+
if (
|
|
864
|
+
not successful_tools_were_used
|
|
865
|
+
and response.content
|
|
866
|
+
and self._contains_unverified_completion_claim(response.content)
|
|
867
|
+
and no_tool_retry_count < self.action_tool_retries
|
|
868
|
+
):
|
|
869
|
+
no_tool_retry_count += 1
|
|
870
|
+
forced_tool_retry = True
|
|
871
|
+
self._emit_activity("hallucination_retry", iteration=iteration)
|
|
872
|
+
logger.warning(
|
|
873
|
+
"Completion claim without tool call; retrying with forced tool instruction "
|
|
874
|
+
f"({no_tool_retry_count}/{self.action_tool_retries})"
|
|
875
|
+
)
|
|
876
|
+
messages.append({
|
|
877
|
+
"role": "user",
|
|
878
|
+
"content": (
|
|
879
|
+
"The previous response claims the action was done but no tool was called. "
|
|
880
|
+
"You must call the appropriate tool now. "
|
|
881
|
+
"Do not claim completion without executing a tool."
|
|
882
|
+
),
|
|
883
|
+
})
|
|
884
|
+
continue
|
|
885
|
+
|
|
886
|
+
if enforce_action_tools and not successful_tools_were_used:
|
|
887
|
+
if no_tool_retry_count < self.action_tool_retries:
|
|
888
|
+
no_tool_retry_count += 1
|
|
889
|
+
logger.warning(
|
|
890
|
+
"Action turn returned no tool call; retrying with corrective instruction "
|
|
891
|
+
f"({no_tool_retry_count}/{self.action_tool_retries})"
|
|
892
|
+
)
|
|
893
|
+
messages.append({
|
|
894
|
+
"role": "user",
|
|
895
|
+
"content": (
|
|
896
|
+
"This is an action request. Call the appropriate tool now. "
|
|
897
|
+
"Do not claim completion without executing a tool."
|
|
898
|
+
),
|
|
899
|
+
})
|
|
900
|
+
continue
|
|
901
|
+
|
|
902
|
+
final_content = "Tool call could not be verified, no action was taken."
|
|
903
|
+
break
|
|
904
|
+
|
|
905
|
+
if forced_tool_retry and not successful_tools_were_used:
|
|
906
|
+
final_content = "Tool call could not be verified, no action was taken."
|
|
907
|
+
break
|
|
908
|
+
|
|
909
|
+
final_content = response.content
|
|
910
|
+
break
|
|
911
|
+
|
|
912
|
+
if enforce_action_tools and not successful_tools_were_used:
|
|
913
|
+
if not final_content or not final_content.startswith("Tool"):
|
|
914
|
+
final_content = "Tool calls failed, no action was taken."
|
|
915
|
+
|
|
916
|
+
if final_content is None:
|
|
917
|
+
if accumulated_tool_results:
|
|
918
|
+
summary = f"Actions completed ({len(accumulated_tool_results)} tools executed):\n"
|
|
919
|
+
for tr in accumulated_tool_results[-5:]:
|
|
920
|
+
status = "✓" if tr["success"] else "✗"
|
|
921
|
+
summary += f" {status} {tr['tool']}\n"
|
|
922
|
+
final_content = summary
|
|
923
|
+
else:
|
|
924
|
+
final_content = "Action completed but no response could be generated."
|
|
925
|
+
|
|
926
|
+
if not final_content or not final_content.strip():
|
|
927
|
+
if enforce_action_tools and not successful_tools_were_used:
|
|
928
|
+
final_content = "Tool call could not be verified, no action was taken."
|
|
929
|
+
elif accumulated_tool_results:
|
|
930
|
+
final_content = "✓ Action completed."
|
|
931
|
+
else:
|
|
932
|
+
final_content = "Action completed but no response could be generated."
|
|
933
|
+
|
|
934
|
+
if (
|
|
935
|
+
final_content
|
|
936
|
+
and not executed_tool_names
|
|
937
|
+
and (action_turn or self._is_retry_action_followup(turn_content))
|
|
938
|
+
and self._contains_unverified_completion_claim(final_content)
|
|
939
|
+
):
|
|
940
|
+
logger.warning("Suppressed unverified completion claim because no tool was executed.")
|
|
941
|
+
final_content = "No tool was executed, no action was taken."
|
|
942
|
+
|
|
943
|
+
logger.info(
|
|
944
|
+
"LLM final telemetry: "
|
|
945
|
+
f"final_content_length={len(final_content)}, executed_tools={executed_tool_names}, "
|
|
946
|
+
f"action_turn={action_turn}, live_call_turn={live_call_turn}, "
|
|
947
|
+
f"blocked_tools={sorted(set(blocked_tools))}"
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
if enforce_action_tools and not executed_tool_names:
|
|
951
|
+
logger.error("Action turn alarm: executed_tools=0")
|
|
952
|
+
|
|
953
|
+
# If the loop produced a hardcoded fallback and tool results exist,
|
|
954
|
+
# ask the model to summarize what happened in natural language.
|
|
955
|
+
if final_content and self._is_hardcoded_fallback(final_content) and accumulated_tool_results:
|
|
956
|
+
logger.info("Requesting model summary turn to replace hardcoded fallback")
|
|
957
|
+
summary = await self._request_summary_turn(messages, accumulated_tool_results)
|
|
958
|
+
if summary:
|
|
959
|
+
final_content = summary
|
|
960
|
+
|
|
961
|
+
self._emit_activity("iteration_end", iteration=iteration)
|
|
962
|
+
return final_content, accumulated_tool_results, executed_tool_names
|
|
963
|
+
|
|
964
|
+
async def _run_memory_flush(
|
|
965
|
+
self,
|
|
966
|
+
session: Any,
|
|
967
|
+
channel: str,
|
|
968
|
+
chat_id: str,
|
|
969
|
+
) -> None:
|
|
970
|
+
"""
|
|
971
|
+
Run a pre-compaction memory flush turn.
|
|
972
|
+
|
|
973
|
+
This gives the agent a chance to save important information
|
|
974
|
+
to disk before context gets compacted.
|
|
975
|
+
"""
|
|
976
|
+
user_prompt, system_prompt = self.compaction.get_memory_flush_prompt()
|
|
977
|
+
|
|
978
|
+
# Build messages with flush prompt
|
|
979
|
+
messages = self.context.build_messages(
|
|
980
|
+
history=session.get_history(max_messages=self.context_messages),
|
|
981
|
+
current_message=user_prompt,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
# Add system prompt for flush context
|
|
985
|
+
messages[0]["content"] += f"\n\n{system_prompt}"
|
|
986
|
+
|
|
987
|
+
# Run a single turn with tools available
|
|
988
|
+
try:
|
|
989
|
+
response = await self.provider.chat(
|
|
990
|
+
messages=messages,
|
|
991
|
+
tools=self.tools.get_definitions(),
|
|
992
|
+
model=self.model
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Execute any tool calls (agent might want to write to memory)
|
|
996
|
+
if response.has_tool_calls:
|
|
997
|
+
for tool_call in response.tool_calls:
|
|
998
|
+
logger.debug(f"Memory flush tool: {tool_call.name}")
|
|
999
|
+
await self.tools.execute(tool_call.name, tool_call.arguments)
|
|
1000
|
+
|
|
1001
|
+
# Check if response should be silent
|
|
1002
|
+
content = response.content or ""
|
|
1003
|
+
if not self.compaction.is_silent_reply(content):
|
|
1004
|
+
# Agent wants to communicate something
|
|
1005
|
+
stripped = self.compaction.strip_silent_token(content)
|
|
1006
|
+
if stripped:
|
|
1007
|
+
logger.info(f"Memory flush response: {stripped[:100]}...")
|
|
1008
|
+
# Optionally send to user
|
|
1009
|
+
await self.bus.publish_outbound(OutboundMessage(
|
|
1010
|
+
channel=channel,
|
|
1011
|
+
chat_id=chat_id,
|
|
1012
|
+
content=f"📝 {stripped}"
|
|
1013
|
+
))
|
|
1014
|
+
|
|
1015
|
+
# Save flush interaction to session
|
|
1016
|
+
session.add_message("user", f"[System: Memory Flush] {user_prompt}")
|
|
1017
|
+
session.add_message("assistant", content)
|
|
1018
|
+
self.sessions.save(session)
|
|
1019
|
+
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
logger.warning(f"Memory flush failed: {e}")
|
|
1022
|
+
|
|
1023
|
+
async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
1024
|
+
"""
|
|
1025
|
+
Process a single inbound message.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
msg: The inbound message to process.
|
|
1029
|
+
|
|
1030
|
+
Returns:
|
|
1031
|
+
The response message, or None if no response needed.
|
|
1032
|
+
"""
|
|
1033
|
+
# Handle system messages (subagent announces)
|
|
1034
|
+
# The chat_id contains the original "channel:chat_id" to route back to
|
|
1035
|
+
if msg.channel == "system":
|
|
1036
|
+
return await self._process_system_message(msg)
|
|
1037
|
+
|
|
1038
|
+
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
|
|
1039
|
+
|
|
1040
|
+
# Handle /new and /clear commands
|
|
1041
|
+
is_command = msg.metadata.get("is_command", False)
|
|
1042
|
+
command = msg.metadata.get("command", "")
|
|
1043
|
+
if is_command and command in ("new", "clear"):
|
|
1044
|
+
session = self.sessions.get_or_create(msg.session_key)
|
|
1045
|
+
session.clear()
|
|
1046
|
+
session.metadata["persona"] = self.context.persona
|
|
1047
|
+
self.sessions.save(session)
|
|
1048
|
+
logger.info(f"Session {msg.session_key} cleared via /{command}")
|
|
1049
|
+
return None # Telegram handler already sent confirmation
|
|
1050
|
+
|
|
1051
|
+
# Get or create session
|
|
1052
|
+
session = self.sessions.get_or_create(msg.session_key)
|
|
1053
|
+
|
|
1054
|
+
# Update tool contexts
|
|
1055
|
+
message_tool = self.tools.get("message")
|
|
1056
|
+
if isinstance(message_tool, MessageTool):
|
|
1057
|
+
message_tool.set_context(msg.channel, msg.chat_id)
|
|
1058
|
+
|
|
1059
|
+
spawn_tool = self.tools.get("spawn")
|
|
1060
|
+
if isinstance(spawn_tool, SpawnTool):
|
|
1061
|
+
spawn_tool.set_context(msg.channel, msg.chat_id)
|
|
1062
|
+
|
|
1063
|
+
# Detect persona change and inject transition marker
|
|
1064
|
+
current_persona = self.context.persona
|
|
1065
|
+
session_persona = session.metadata.get("persona")
|
|
1066
|
+
if session_persona and session_persona != current_persona and session.messages:
|
|
1067
|
+
logger.info(f"Persona changed: {session_persona} → {current_persona}")
|
|
1068
|
+
session.add_message(
|
|
1069
|
+
"system",
|
|
1070
|
+
f"[PERSONA CHANGE] The assistant's persona has been changed from "
|
|
1071
|
+
f"'{session_persona}' to '{current_persona}'. From this point forward, "
|
|
1072
|
+
f"respond strictly as the new persona. Ignore the style/tone of previous "
|
|
1073
|
+
f"messages in this conversation."
|
|
1074
|
+
)
|
|
1075
|
+
session.metadata["persona"] = current_persona
|
|
1076
|
+
|
|
1077
|
+
# Get history and check for compaction
|
|
1078
|
+
history = session.get_history(max_messages=self.context_messages)
|
|
1079
|
+
|
|
1080
|
+
# Check if memory flush is needed before potential compaction
|
|
1081
|
+
total_tokens = estimate_messages_tokens(history)
|
|
1082
|
+
if self.compaction.should_memory_flush(total_tokens):
|
|
1083
|
+
logger.info("Running pre-compaction memory flush")
|
|
1084
|
+
await self._run_memory_flush(session, msg.channel, msg.chat_id)
|
|
1085
|
+
self.compaction.mark_memory_flush_done()
|
|
1086
|
+
# Reload history after flush
|
|
1087
|
+
history = session.get_history(max_messages=self.context_messages)
|
|
1088
|
+
total_tokens = estimate_messages_tokens(history)
|
|
1089
|
+
|
|
1090
|
+
# Check if compaction is needed
|
|
1091
|
+
if self.compaction.should_compact(total_tokens):
|
|
1092
|
+
logger.info(f"Compacting context: {total_tokens} tokens exceeds threshold")
|
|
1093
|
+
result = await self.compaction.compact(history)
|
|
1094
|
+
logger.info(
|
|
1095
|
+
f"Compaction complete: {result.tokens_before} -> {result.tokens_after} tokens, "
|
|
1096
|
+
f"removed {result.messages_removed} messages"
|
|
1097
|
+
)
|
|
1098
|
+
# Replace history with summary
|
|
1099
|
+
history = [{"role": "system", "content": f"[Previous conversation summary]\n\n{result.summary}"}]
|
|
1100
|
+
# Update session with compacted history
|
|
1101
|
+
session.metadata["last_compaction_summary"] = result.summary
|
|
1102
|
+
|
|
1103
|
+
# Build initial messages
|
|
1104
|
+
messages = self.context.build_messages(
|
|
1105
|
+
history=history,
|
|
1106
|
+
current_message=msg.content,
|
|
1107
|
+
media=msg.media if msg.media else None,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
action_turn = self._is_action_turn(msg.channel, msg.content)
|
|
1111
|
+
if not action_turn and self._should_promote_retry_to_action(msg.content, history):
|
|
1112
|
+
action_turn = True
|
|
1113
|
+
if not action_turn and self._consume_pending_action_lock(session, msg.content):
|
|
1114
|
+
action_turn = True
|
|
1115
|
+
logger.info("Pending action lock promoted this turn to action_turn=True")
|
|
1116
|
+
live_call_turn = self._is_live_call_turn(msg.content)
|
|
1117
|
+
final_content, tool_results, _executed_tools = await self._run_llm_tool_loop(
|
|
1118
|
+
messages=messages,
|
|
1119
|
+
action_turn=action_turn,
|
|
1120
|
+
live_call_turn=live_call_turn,
|
|
1121
|
+
turn_content=msg.content,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
if action_turn:
|
|
1125
|
+
successful_tools = [r for r in tool_results if r.get("success")]
|
|
1126
|
+
if successful_tools:
|
|
1127
|
+
self._clear_pending_action_lock(session)
|
|
1128
|
+
else:
|
|
1129
|
+
self._set_pending_action_lock(session, msg.content)
|
|
1130
|
+
logger.warning("Action turn ended without successful tool execution; pending lock armed.")
|
|
1131
|
+
|
|
1132
|
+
# Save to session
|
|
1133
|
+
session.add_message("user", msg.content)
|
|
1134
|
+
session.add_message("assistant", final_content)
|
|
1135
|
+
self.sessions.save(session)
|
|
1136
|
+
|
|
1137
|
+
return OutboundMessage(
|
|
1138
|
+
channel=msg.channel,
|
|
1139
|
+
chat_id=msg.chat_id,
|
|
1140
|
+
content=final_content
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
1144
|
+
"""
|
|
1145
|
+
Process a system message (e.g., subagent announce).
|
|
1146
|
+
|
|
1147
|
+
The chat_id field contains "original_channel:original_chat_id" to route
|
|
1148
|
+
the response back to the correct destination.
|
|
1149
|
+
"""
|
|
1150
|
+
logger.info(f"Processing system message from {msg.sender_id}")
|
|
1151
|
+
|
|
1152
|
+
# Parse origin from chat_id (format: "channel:chat_id")
|
|
1153
|
+
if ":" in msg.chat_id:
|
|
1154
|
+
parts = msg.chat_id.split(":", 1)
|
|
1155
|
+
origin_channel = parts[0]
|
|
1156
|
+
origin_chat_id = parts[1]
|
|
1157
|
+
else:
|
|
1158
|
+
# Fallback
|
|
1159
|
+
origin_channel = "cli"
|
|
1160
|
+
origin_chat_id = msg.chat_id
|
|
1161
|
+
|
|
1162
|
+
# Use the origin session for context
|
|
1163
|
+
session_key = f"{origin_channel}:{origin_chat_id}"
|
|
1164
|
+
session = self.sessions.get_or_create(session_key)
|
|
1165
|
+
|
|
1166
|
+
# Update tool contexts
|
|
1167
|
+
message_tool = self.tools.get("message")
|
|
1168
|
+
if isinstance(message_tool, MessageTool):
|
|
1169
|
+
message_tool.set_context(origin_channel, origin_chat_id)
|
|
1170
|
+
|
|
1171
|
+
spawn_tool = self.tools.get("spawn")
|
|
1172
|
+
if isinstance(spawn_tool, SpawnTool):
|
|
1173
|
+
spawn_tool.set_context(origin_channel, origin_chat_id)
|
|
1174
|
+
|
|
1175
|
+
# Build messages with the announce content
|
|
1176
|
+
messages = self.context.build_messages(
|
|
1177
|
+
history=session.get_history(max_messages=self.context_messages),
|
|
1178
|
+
current_message=msg.content
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
action_turn = self._is_action_turn(origin_channel, msg.content)
|
|
1182
|
+
if not action_turn and self._should_promote_retry_to_action(
|
|
1183
|
+
msg.content,
|
|
1184
|
+
session.get_history(max_messages=self.context_messages),
|
|
1185
|
+
):
|
|
1186
|
+
action_turn = True
|
|
1187
|
+
if not action_turn and self._consume_pending_action_lock(session, msg.content):
|
|
1188
|
+
action_turn = True
|
|
1189
|
+
logger.info("Pending action lock promoted system turn to action_turn=True")
|
|
1190
|
+
live_call_turn = self._is_live_call_turn(msg.content)
|
|
1191
|
+
# Progress callback: prefer custom callback from metadata, fallback to bus
|
|
1192
|
+
custom_progress = (msg.metadata or {}).pop("on_progress", None)
|
|
1193
|
+
|
|
1194
|
+
async def _bus_progress(hint: str) -> None:
|
|
1195
|
+
await self.bus.publish_outbound(OutboundMessage(
|
|
1196
|
+
channel=msg.channel, chat_id=msg.chat_id,
|
|
1197
|
+
content=f"↳ {hint}",
|
|
1198
|
+
metadata={"progress": True},
|
|
1199
|
+
))
|
|
1200
|
+
|
|
1201
|
+
progress_fn = custom_progress if callable(custom_progress) else _bus_progress
|
|
1202
|
+
|
|
1203
|
+
final_content, tool_results, _executed_tools = await self._run_llm_tool_loop(
|
|
1204
|
+
messages=messages,
|
|
1205
|
+
action_turn=action_turn,
|
|
1206
|
+
live_call_turn=live_call_turn,
|
|
1207
|
+
turn_content=msg.content,
|
|
1208
|
+
on_progress=progress_fn,
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
if action_turn:
|
|
1212
|
+
successful_tools = [r for r in tool_results if r.get("success")]
|
|
1213
|
+
if successful_tools:
|
|
1214
|
+
self._clear_pending_action_lock(session)
|
|
1215
|
+
else:
|
|
1216
|
+
self._set_pending_action_lock(session, msg.content)
|
|
1217
|
+
|
|
1218
|
+
# Save to session (mark as system message in history)
|
|
1219
|
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
|
1220
|
+
session.add_message("assistant", final_content)
|
|
1221
|
+
self.sessions.save(session)
|
|
1222
|
+
|
|
1223
|
+
return OutboundMessage(
|
|
1224
|
+
channel=origin_channel,
|
|
1225
|
+
chat_id=origin_chat_id,
|
|
1226
|
+
content=final_content
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
async def process_direct(
|
|
1230
|
+
self,
|
|
1231
|
+
content: str,
|
|
1232
|
+
session_key: str = "cli:direct",
|
|
1233
|
+
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
|
1234
|
+
) -> str:
|
|
1235
|
+
"""
|
|
1236
|
+
Process a message directly (for CLI usage or voice calls).
|
|
1237
|
+
|
|
1238
|
+
Args:
|
|
1239
|
+
content: The message content.
|
|
1240
|
+
session_key: Session identifier in format "channel:chat_id".
|
|
1241
|
+
|
|
1242
|
+
Returns:
|
|
1243
|
+
The agent's response.
|
|
1244
|
+
"""
|
|
1245
|
+
# Parse session_key to extract channel and chat_id
|
|
1246
|
+
if ":" in session_key:
|
|
1247
|
+
channel, chat_id = session_key.split(":", 1)
|
|
1248
|
+
else:
|
|
1249
|
+
channel, chat_id = "cli", session_key
|
|
1250
|
+
|
|
1251
|
+
await self._connect_mcp()
|
|
1252
|
+
|
|
1253
|
+
msg = InboundMessage(
|
|
1254
|
+
channel=channel,
|
|
1255
|
+
sender_id="user",
|
|
1256
|
+
chat_id=chat_id,
|
|
1257
|
+
content=content,
|
|
1258
|
+
metadata={"on_progress": on_progress} if on_progress else {},
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
response = await self._process_message(msg)
|
|
1262
|
+
return response.content if response else ""
|
|
1263
|
+
|
|
1264
|
+
async def compact_session(
|
|
1265
|
+
self,
|
|
1266
|
+
session_key: str,
|
|
1267
|
+
custom_instructions: str | None = None,
|
|
1268
|
+
) -> dict[str, Any]:
|
|
1269
|
+
"""
|
|
1270
|
+
Manually compact a session's history.
|
|
1271
|
+
|
|
1272
|
+
Args:
|
|
1273
|
+
session_key: Session identifier.
|
|
1274
|
+
custom_instructions: Optional instructions for summarization.
|
|
1275
|
+
|
|
1276
|
+
Returns:
|
|
1277
|
+
Dict with compaction results.
|
|
1278
|
+
"""
|
|
1279
|
+
session = self.sessions.get_or_create(session_key)
|
|
1280
|
+
history = session.get_history(max_messages=self.context_messages)
|
|
1281
|
+
|
|
1282
|
+
if not history:
|
|
1283
|
+
return {
|
|
1284
|
+
"success": False,
|
|
1285
|
+
"message": "No history to compact.",
|
|
1286
|
+
"tokens_before": 0,
|
|
1287
|
+
"tokens_after": 0,
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
tokens_before = estimate_messages_tokens(history)
|
|
1291
|
+
|
|
1292
|
+
# Check if already compacted (first message is a compaction summary)
|
|
1293
|
+
is_already_compacted = (
|
|
1294
|
+
len(history) == 1
|
|
1295
|
+
and history[0].get("role") == "system"
|
|
1296
|
+
and "[Compacted conversation summary]" in history[0].get("content", "")
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
if is_already_compacted:
|
|
1300
|
+
return {
|
|
1301
|
+
"success": False,
|
|
1302
|
+
"message": "Already compacted. Send more messages first.",
|
|
1303
|
+
"tokens_before": tokens_before,
|
|
1304
|
+
"tokens_after": tokens_before,
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
# Check if too few messages to compact (need at least 3 messages)
|
|
1308
|
+
# Filter out system messages for this count
|
|
1309
|
+
user_assistant_messages = [m for m in history if m.get("role") in ("user", "assistant")]
|
|
1310
|
+
if len(user_assistant_messages) < 3:
|
|
1311
|
+
return {
|
|
1312
|
+
"success": False,
|
|
1313
|
+
"message": f"Not enough messages to compact ({len(user_assistant_messages)} messages). Need at least 3.",
|
|
1314
|
+
"tokens_before": tokens_before,
|
|
1315
|
+
"tokens_after": tokens_before,
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
# Check if token count is too low to bother compacting (< 1000 tokens)
|
|
1319
|
+
if tokens_before < 1000:
|
|
1320
|
+
return {
|
|
1321
|
+
"success": False,
|
|
1322
|
+
"message": f"History too small to compact ({tokens_before} tokens). Need at least 1000.",
|
|
1323
|
+
"tokens_before": tokens_before,
|
|
1324
|
+
"tokens_after": tokens_before,
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
# Run compaction
|
|
1328
|
+
result = await self.compaction.compact(
|
|
1329
|
+
history,
|
|
1330
|
+
custom_instructions=custom_instructions,
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
# Clear session and add summary as first message
|
|
1334
|
+
session.clear()
|
|
1335
|
+
session.add_message(
|
|
1336
|
+
"system",
|
|
1337
|
+
f"[Compacted conversation summary]\n\n{result.summary}"
|
|
1338
|
+
)
|
|
1339
|
+
session.metadata["last_compaction_summary"] = result.summary
|
|
1340
|
+
session.metadata["compaction_count"] = session.metadata.get("compaction_count", 0) + 1
|
|
1341
|
+
self.sessions.save(session)
|
|
1342
|
+
|
|
1343
|
+
return {
|
|
1344
|
+
"success": True,
|
|
1345
|
+
"message": f"Compacted {result.messages_removed} messages",
|
|
1346
|
+
"tokens_before": result.tokens_before,
|
|
1347
|
+
"tokens_after": result.tokens_after,
|
|
1348
|
+
"summary_preview": result.summary[:200] + "..." if len(result.summary) > 200 else result.summary,
|
|
1349
|
+
}
|