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.
Files changed (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. 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
+ }