main
py 567 lines 19.2 KB
Raw
1 from __future__ import annotations
2
3 import re
4 from dataclasses import dataclass
5 from typing import TYPE_CHECKING
6
7 from helpers import message_queue as mq
8 from helpers import projects
9 from helpers.persist_chat import save_tmp_chat
10 from helpers.state_monitor_integration import mark_dirty_for_context
11 from plugins._model_config.helpers import model_config
12
13 if TYPE_CHECKING:
14 from agent import AgentContext
15
16
17 _CLEAR_VALUES = {"", "default", "none", "clear", "off"}
18 _MODEL_INHERIT_VALUES = {"", "none", "clear", "off", "inherit"}
19
20
21 @dataclass(frozen=True)
22 class IntegrationCommandDef:
23 name: str
24 description: str
25 category: str
26 aliases: tuple[str, ...] = ()
27 args_hint: str = ""
28 menu: bool = True
29 integrations: tuple[str, ...] = ()
30
31
32 COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = (
33 IntegrationCommandDef("commands", "Show all integration commands.", "Info", aliases=("help",)),
34 IntegrationCommandDef(
35 "status",
36 "Show this chat's project, model, agent, and queue state.",
37 "Info",
38 ),
39 IntegrationCommandDef("new", "Start a fresh chat context.", "Session"),
40 IntegrationCommandDef(
41 "sessions",
42 "Show or switch recent chat sessions.",
43 "Session",
44 aliases=("session",),
45 integrations=("telegram",),
46 ),
47 IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)),
48 IntegrationCommandDef(
49 "queue",
50 "Show or manage queued messages.",
51 "Session",
52 args_hint="[send|clear]",
53 ),
54 IntegrationCommandDef("send", "Send queued messages now.", "Session", aliases=("push",)),
55 IntegrationCommandDef(
56 "steer",
57 "Intervene in the currently running task.",
58 "Session",
59 args_hint="<message>",
60 ),
61 IntegrationCommandDef("pause", "Pause the active run.", "Session"),
62 IntegrationCommandDef("resume", "Resume a paused run.", "Session"),
63 IntegrationCommandDef("nudge", "Nudge the active run.", "Session"),
64 IntegrationCommandDef(
65 "stream",
66 "Enable or disable Telegram response streaming.",
67 "Configuration",
68 args_hint="[on|off]",
69 integrations=("telegram",),
70 ),
71 IntegrationCommandDef(
72 "tools",
73 "Show or hide Telegram tool progress.",
74 "Configuration",
75 args_hint="[on|off]",
76 integrations=("telegram",),
77 ),
78 IntegrationCommandDef(
79 "project",
80 "Show or switch the active project.",
81 "Configuration",
82 args_hint="[name|none]",
83 ),
84 IntegrationCommandDef(
85 "model",
86 "Show or switch the chat model preset.",
87 "Configuration",
88 aliases=("config", "preset"),
89 args_hint="[preset|inherit]",
90 ),
91 IntegrationCommandDef(
92 "agent",
93 "Show or switch the agent profile.",
94 "Configuration",
95 aliases=("profile",),
96 args_hint="[profile]",
97 ),
98 )
99
100
101 _COMMAND_LOOKUP = {
102 f"/{name}": command
103 for command in COMMAND_REGISTRY
104 for name in (command.name, *command.aliases)
105 }
106
107
108 def extract_command_line(text: str) -> str:
109 for line in (text or "").splitlines():
110 stripped = line.strip()
111 if not stripped:
112 continue
113 return stripped
114 return ""
115
116
117 def parse_command(text: str, *, integration: str | None = None) -> tuple[str, str] | None:
118 line = extract_command_line(text)
119 if not line.startswith("/"):
120 return None
121
122 command, _, args = line.partition(" ")
123 command = _normalize_command_token(command)
124 resolved = resolve_command(command, integration=integration)
125 if not resolved:
126 return None
127
128 return f"/{resolved.name}", args.strip()
129
130
131 def resolve_command(command: str, *, integration: str | None = None) -> IntegrationCommandDef | None:
132 normalized = _normalize_command_token(command)
133 if not normalized.startswith("/"):
134 normalized = f"/{normalized}"
135 command_def = _COMMAND_LOOKUP.get(normalized)
136 if not command_def or not _is_command_available(command_def, integration):
137 return None
138 return command_def
139
140
141 def telegram_menu_commands() -> list[tuple[str, str]]:
142 return [
143 (command.name, _telegram_description(command))
144 for command in COMMAND_REGISTRY
145 if command.menu and _is_command_available(command, "telegram")
146 ]
147
148
149 def command_names(include_aliases: bool = True, *, integration: str | None = None) -> list[str]:
150 names: list[str] = []
151 for command in COMMAND_REGISTRY:
152 if not _is_command_available(command, integration):
153 continue
154 names.append(command.name)
155 if include_aliases:
156 names.extend(command.aliases)
157 return names
158
159
160 def help_text(*, full: bool = False, integration: str | None = None) -> str:
161 commands = tuple(
162 command
163 for command in COMMAND_REGISTRY
164 if _is_command_available(command, integration) and (full or command.menu)
165 )
166 lines = ["Available commands:"]
167 for command in commands:
168 args = f" {command.args_hint}" if command.args_hint else ""
169 alias_text = ""
170 if command.aliases:
171 alias_text = f" (alias: {', '.join('/' + alias for alias in command.aliases)})"
172 lines.append(f"/{command.name}{args} - {command.description}{alias_text}")
173 return "\n".join(lines)
174
175
176 def unknown_command_text(command: str, *, integration: str | None = None) -> str:
177 token = _normalize_command_token(command).split(" ", 1)[0]
178 return f"Unknown command: {token}\n\n{help_text(full=True, integration=integration)}"
179
180
181 def try_handle_command(
182 context: "AgentContext",
183 text: str,
184 *,
185 integration: str | None = None,
186 ) -> str | None:
187 parsed = parse_command(text, integration=integration)
188 if not parsed:
189 return None
190
191 command, args = parsed
192 if command == "/commands":
193 return help_text(full=True, integration=integration)
194 if command == "/status":
195 return _handle_status(context)
196 if command == "/sessions":
197 return _handle_sessions(context)
198 if command in {"/new", "/clear"}:
199 return _handle_clear(context, new_chat=(command == "/new"))
200 if command == "/send":
201 return _handle_queue(context, "send")
202 if command == "/queue":
203 return _handle_queue(context, args)
204 if command == "/steer":
205 return _handle_steer(context, args)
206 if command == "/pause":
207 return _handle_pause(context)
208 if command == "/resume":
209 return _handle_resume(context)
210 if command == "/nudge":
211 return _handle_nudge(context)
212 if command == "/stream":
213 return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming")
214 if command == "/tools":
215 return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress")
216 if command == "/project":
217 return _handle_project(context, args)
218 if command == "/model":
219 return _handle_model(context, args)
220 if command == "/agent":
221 return _handle_agent(context, args)
222 return None
223
224
225 def _normalize_command_token(command: str) -> str:
226 normalized = command.strip().lower()
227 if not normalized:
228 return ""
229 token, *rest = normalized.split(" ", 1)
230 if "@" in token:
231 token = token.split("@", 1)[0]
232 return f"{token} {rest[0]}".strip() if rest else token
233
234
235 def _is_command_available(command: IntegrationCommandDef, integration: str | None) -> bool:
236 if not command.integrations:
237 return True
238 if not integration:
239 return False
240 return integration.lower() in command.integrations
241
242
243 def _handle_queue(context: "AgentContext", args: str) -> str:
244 queue = mq.get_queue(context)
245 count = len(queue)
246 action = args.strip().lower()
247
248 if not action:
249 noun = "message" if count == 1 else "messages"
250 return (
251 f"Queue has {count} {noun}.\n"
252 "Use /send or /queue send to send everything as one batch."
253 )
254
255 if action in {"clear", "reset"}:
256 mq.remove(context)
257 mark_dirty_for_context(context.id, reason="integration_commands.queue_clear")
258 return "Queue cleared."
259
260 if action not in {"send", "all"}:
261 return "Unknown queue action. Use /queue send to flush or /queue clear to clear."
262
263 if count == 0:
264 return "Queue is empty."
265
266 sent_count = mq.send_all_aggregated(context)
267 mark_dirty_for_context(context.id, reason="integration_commands.queue_send")
268 noun = "message" if sent_count == 1 else "messages"
269 return f"Sent {sent_count} queued {noun} as one batch."
270
271
272 def _handle_status(context: "AgentContext") -> str:
273 project_name = context.get_data("project") or "none"
274 agent_profile = getattr(context.agent0.config, "profile", "default")
275 running = "running" if context.is_running() else "idle"
276 if getattr(context, "paused", False):
277 running = "paused"
278 queue_count = len(mq.get_queue(context))
279 return (
280 f"Status: {running}\n"
281 f"Project: {project_name}\n"
282 f"Model: {model_config.get_effective_preset_name(context.agent0)}\n"
283 f"Agent: {agent_profile}\n"
284 f"Queued messages: {queue_count}"
285 )
286
287
288 def _handle_sessions(context: "AgentContext") -> str:
289 from agent import AgentContext
290
291 contexts = sorted(
292 AgentContext.all(),
293 key=lambda item: str(item.output().get("last_message") or ""),
294 reverse=True,
295 )
296 lines = ["Recent sessions:"]
297 for item in contexts[:4]:
298 marker = " (current)" if item.id == context.id else ""
299 running = " - running" if item.is_running() else ""
300 lines.append(f"- {item.name or item.id}{marker}{running}")
301 if len(contexts) > 4:
302 lines.append(f"And {len(contexts) - 4} more. Use Telegram buttons to page through them.")
303 return "\n".join(lines)
304
305
306 def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str:
307 context.reset()
308 mq.remove(context)
309 save_tmp_chat(context)
310 reason = "integration_commands.new" if new_chat else "integration_commands.clear"
311 mark_dirty_for_context(context.id, reason=reason)
312 return "Started a fresh chat." if new_chat else "Chat cleared."
313
314
315 def _handle_steer(context: "AgentContext", args: str) -> str:
316 message = args.strip()
317 if not message:
318 return "Usage: /steer <message>"
319 from agent import UserMessage
320
321 context.communicate(UserMessage(message=message))
322 if context.is_running():
323 return "Steering message sent to the active run."
324 return "Message sent."
325
326
327 def _handle_pause(context: "AgentContext") -> str:
328 if not context.is_running():
329 return "No active run is currently running."
330 context.paused = True
331 return "Agent paused."
332
333
334 def _handle_resume(context: "AgentContext") -> str:
335 context.paused = False
336 return "Agent resumed."
337
338
339 def _handle_nudge(context: "AgentContext") -> str:
340 context.nudge()
341 return "Agent nudged."
342
343
344 def _handle_toggle(context: "AgentContext", args: str, key: str, label: str) -> str:
345 value = _parse_toggle(args)
346 current = _get_toggle(context, key)
347 if value is None:
348 state = "on" if current else "off"
349 return f"{label}: {state}. Use /{key.split('_')[1]} on or /{key.split('_')[1]} off."
350 context.set_data(key, value)
351 save_tmp_chat(context)
352 mark_dirty_for_context(context.id, reason=f"integration_commands.{key}")
353 return f"{label} {'enabled' if value else 'disabled'}."
354
355
356 def _handle_project(context: "AgentContext", args: str) -> str:
357 items = projects.get_active_projects_list() or []
358 current_name = context.get_data("project") or ""
359
360 if not args:
361 current_label = _describe_project(items, current_name)
362 available = ", ".join(_format_project_entry(item) for item in items) or "none"
363 return (
364 f"Current project: {current_label}\n"
365 f"Available projects: {available}\n"
366 "Use /project <name> to switch, or /project none to clear it."
367 )
368
369 desired = _strip_quotes(args)
370 if _normalize_lookup(desired) in _CLEAR_VALUES:
371 if not current_name:
372 return "No project is active."
373 projects.deactivate_project(context.id)
374 return "Cleared the active project."
375
376 match, ambiguous = _match_named_item(items, desired, keys=("name", "title"))
377 if ambiguous:
378 names = ", ".join(_format_project_entry(item) for item in ambiguous)
379 return f"Project name is ambiguous. Matches: {names}"
380 if not match:
381 available = ", ".join(_format_project_entry(item) for item in items) or "none"
382 return f"Project '{desired}' was not found. Available projects: {available}"
383
384 if match.get("name") == current_name:
385 return f"Already using project {match.get('title') or match.get('name')}."
386
387 projects.activate_project(context.id, match["name"])
388 return f"Switched project to {match.get('title') or match['name']}."
389
390
391 def _handle_model(context: "AgentContext", args: str) -> str:
392 allowed = model_config.is_chat_override_allowed(context.agent0)
393 presets = [preset for preset in model_config.get_presets() if preset.get("name")]
394 current_override = context.get_data("chat_model_override")
395
396 if not args:
397 current_label = model_config.get_effective_preset_name(context.agent0)
398 available = ", ".join(preset["name"] for preset in presets) or "none"
399 suffix = "Use /model <name> to switch, or /model inherit to use the scoped preset."
400 if not allowed:
401 suffix = "Per-chat config switching is disabled in Model Configuration."
402 return (
403 f"Current model: {current_label}\n"
404 f"Available presets: {available}\n"
405 f"{suffix}"
406 )
407
408 if not allowed:
409 return "Config switching is disabled in Model Configuration."
410
411 desired = _strip_quotes(args)
412 if _normalize_lookup(desired) in _MODEL_INHERIT_VALUES:
413 if not current_override:
414 return "Already using the scoped model preset."
415 context.set_data("chat_model_override", None)
416 save_tmp_chat(context)
417 mark_dirty_for_context(context.id, reason="integration_commands.config_clear")
418 inherited = model_config.get_effective_preset_name(context.agent0)
419 return f"Switched back to the scoped model preset: {inherited}."
420
421 match, ambiguous = _match_named_item(presets, desired, keys=("name",))
422 if ambiguous:
423 names = ", ".join(item["name"] for item in ambiguous)
424 return f"Config name is ambiguous. Matches: {names}"
425 if not match:
426 available = ", ".join(preset["name"] for preset in presets) or "none"
427 return f"Config '{desired}' was not found. Available configs: {available}"
428
429 preset_name = match["name"]
430 if isinstance(current_override, dict) and current_override.get("preset_name") == preset_name:
431 return f"Already using config {preset_name}."
432
433 context.set_data("chat_model_override", {"preset_name": preset_name})
434 save_tmp_chat(context)
435 mark_dirty_for_context(context.id, reason="integration_commands.config_set")
436 return f"Switched model preset to {preset_name}."
437
438
439 def _handle_agent(context: "AgentContext", args: str) -> str:
440 from helpers import subagents
441 from initialize import initialize_agent
442
443 items = subagents.get_all_agents_list()
444 current = getattr(context.agent0.config, "profile", "default")
445 if not args:
446 available = ", ".join(_format_agent_entry(item) for item in items) or "none"
447 return (
448 f"Current agent: {current}\n"
449 f"Available agents: {available}\n"
450 "Use /agent <profile> to switch after the current run finishes."
451 )
452
453 if context.is_running():
454 return "Agent profile can be changed after the current run finishes."
455
456 desired = _strip_quotes(args)
457 match, ambiguous = _match_named_item(items, desired, keys=("key", "label"))
458 if ambiguous:
459 names = ", ".join(_format_agent_entry(item) for item in ambiguous)
460 return f"Agent profile is ambiguous. Matches: {names}"
461 if not match:
462 available = ", ".join(_format_agent_entry(item) for item in items) or "none"
463 return f"Agent profile '{desired}' was not found. Available agents: {available}"
464
465 profile = str(match["key"])
466 if profile == current:
467 return f"Already using agent {match.get('label') or profile}."
468
469 config = initialize_agent(override_settings={"agent_profile": profile})
470 context.config = config
471 context.agent0.config = config
472 save_tmp_chat(context)
473 mark_dirty_for_context(context.id, reason="integration_commands.agent_set")
474 return f"Switched agent to {match.get('label') or profile}."
475
476
477 def _format_project_entry(item: dict) -> str:
478 title = str(item.get("title", "") or "").strip()
479 name = str(item.get("name", "") or "").strip()
480 if title and title.lower() != name.lower():
481 return f"{title} ({name})"
482 return name or title
483
484
485 def _format_agent_entry(item: dict) -> str:
486 key = str(item.get("key", "") or "").strip()
487 label = str(item.get("label", "") or "").strip()
488 if label and label.lower() != key.lower():
489 return f"{label} ({key})"
490 return key or label
491
492
493 def _telegram_description(command: IntegrationCommandDef) -> str:
494 description = command.description.strip()
495 return description[:255] if len(description) > 255 else description
496
497
498 def _describe_project(items: list[dict], current_name: str) -> str:
499 if not current_name:
500 return "none"
501 for item in items:
502 if item.get("name") == current_name:
503 return item.get("title") or current_name
504 return current_name
505
506
507 def _strip_quotes(value: str) -> str:
508 trimmed = value.strip()
509 if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in {'"', "'"}:
510 return trimmed[1:-1].strip()
511 return trimmed
512
513
514 def _normalize_lookup(value: str) -> str:
515 lowered = value.lower().strip()
516 lowered = re.sub(r"[\s_\-]+", " ", lowered)
517 lowered = re.sub(r"[^a-z0-9 ]+", "", lowered)
518 return lowered.strip()
519
520
521 def _get_toggle(context: "AgentContext", key: str) -> bool:
522 value = context.get_data(key)
523 return True if value is None else bool(value)
524
525
526 def _parse_toggle(args: str) -> bool | None:
527 value = _normalize_lookup(args)
528 if value in {"on", "enable", "enabled", "yes", "true", "1"}:
529 return True
530 if value in {"off", "disable", "disabled", "no", "false", "0"}:
531 return False
532 return None
533
534
535 def _match_named_item(
536 items: list[dict],
537 desired: str,
538 *,
539 keys: tuple[str, ...],
540 ) -> tuple[dict | None, list[dict]]:
541 normalized = _normalize_lookup(desired)
542 exact_matches: list[dict] = []
543
544 for item in items:
545 values = [str(item.get(key, "") or "") for key in keys]
546 normalized_values = [_normalize_lookup(value) for value in values if value]
547 if normalized in normalized_values:
548 exact_matches.append(item)
549
550 if len(exact_matches) == 1:
551 return exact_matches[0], []
552 if len(exact_matches) > 1:
553 return None, exact_matches
554
555 partial_matches: list[dict] = []
556 for item in items:
557 values = [str(item.get(key, "") or "") for key in keys]
558 normalized_values = [_normalize_lookup(value) for value in values if value]
559 if any(normalized and normalized in value for value in normalized_values):
560 partial_matches.append(item)
561
562 if len(partial_matches) == 1:
563 return partial_matches[0], []
564 if len(partial_matches) > 1:
565 return None, partial_matches
566
567 return None, []