agno 2.2.10__py3-none-any.whl → 2.2.12__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 (73) hide show
  1. agno/agent/agent.py +75 -48
  2. agno/db/dynamo/utils.py +1 -1
  3. agno/db/firestore/utils.py +1 -1
  4. agno/db/gcs_json/utils.py +1 -1
  5. agno/db/in_memory/utils.py +1 -1
  6. agno/db/json/utils.py +1 -1
  7. agno/db/mongo/utils.py +3 -3
  8. agno/db/mysql/mysql.py +1 -1
  9. agno/db/mysql/utils.py +1 -1
  10. agno/db/postgres/utils.py +1 -1
  11. agno/db/redis/utils.py +1 -1
  12. agno/db/singlestore/singlestore.py +1 -1
  13. agno/db/singlestore/utils.py +1 -1
  14. agno/db/sqlite/async_sqlite.py +1 -1
  15. agno/db/sqlite/sqlite.py +1 -1
  16. agno/db/sqlite/utils.py +1 -1
  17. agno/filters.py +354 -0
  18. agno/knowledge/chunking/agentic.py +8 -9
  19. agno/knowledge/chunking/strategy.py +59 -15
  20. agno/knowledge/embedder/sentence_transformer.py +6 -2
  21. agno/knowledge/knowledge.py +43 -22
  22. agno/knowledge/reader/base.py +6 -2
  23. agno/knowledge/utils.py +20 -0
  24. agno/models/anthropic/claude.py +45 -9
  25. agno/models/base.py +4 -0
  26. agno/os/app.py +23 -7
  27. agno/os/interfaces/slack/router.py +53 -33
  28. agno/os/interfaces/slack/slack.py +9 -1
  29. agno/os/router.py +25 -1
  30. agno/os/routers/health.py +5 -3
  31. agno/os/routers/knowledge/knowledge.py +43 -17
  32. agno/os/routers/knowledge/schemas.py +4 -3
  33. agno/run/agent.py +11 -1
  34. agno/run/base.py +3 -2
  35. agno/session/agent.py +10 -5
  36. agno/team/team.py +57 -18
  37. agno/tools/file_generation.py +4 -4
  38. agno/tools/gmail.py +179 -0
  39. agno/tools/parallel.py +314 -0
  40. agno/utils/agent.py +22 -17
  41. agno/utils/gemini.py +15 -5
  42. agno/utils/knowledge.py +12 -5
  43. agno/utils/log.py +1 -0
  44. agno/utils/models/claude.py +2 -1
  45. agno/utils/print_response/agent.py +5 -4
  46. agno/utils/print_response/team.py +5 -4
  47. agno/vectordb/base.py +2 -4
  48. agno/vectordb/cassandra/cassandra.py +12 -5
  49. agno/vectordb/chroma/chromadb.py +10 -4
  50. agno/vectordb/clickhouse/clickhousedb.py +12 -4
  51. agno/vectordb/couchbase/couchbase.py +12 -3
  52. agno/vectordb/lancedb/lance_db.py +69 -144
  53. agno/vectordb/langchaindb/langchaindb.py +13 -4
  54. agno/vectordb/lightrag/lightrag.py +8 -3
  55. agno/vectordb/llamaindex/llamaindexdb.py +10 -4
  56. agno/vectordb/milvus/milvus.py +16 -5
  57. agno/vectordb/mongodb/mongodb.py +14 -3
  58. agno/vectordb/pgvector/pgvector.py +73 -15
  59. agno/vectordb/pineconedb/pineconedb.py +6 -2
  60. agno/vectordb/qdrant/qdrant.py +25 -13
  61. agno/vectordb/redis/redisdb.py +37 -30
  62. agno/vectordb/singlestore/singlestore.py +9 -4
  63. agno/vectordb/surrealdb/surrealdb.py +13 -3
  64. agno/vectordb/upstashdb/upstashdb.py +8 -5
  65. agno/vectordb/weaviate/weaviate.py +29 -12
  66. agno/workflow/step.py +3 -2
  67. agno/workflow/types.py +20 -1
  68. agno/workflow/workflow.py +103 -14
  69. {agno-2.2.10.dist-info → agno-2.2.12.dist-info}/METADATA +4 -1
  70. {agno-2.2.10.dist-info → agno-2.2.12.dist-info}/RECORD +73 -71
  71. {agno-2.2.10.dist-info → agno-2.2.12.dist-info}/WHEEL +0 -0
  72. {agno-2.2.10.dist-info → agno-2.2.12.dist-info}/licenses/LICENSE +0 -0
  73. {agno-2.2.10.dist-info → agno-2.2.12.dist-info}/top_level.txt +0 -0
agno/session/agent.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import asdict, dataclass
4
- from typing import Any, Dict, List, Mapping, Optional
4
+ from typing import Any, Dict, List, Mapping, Optional, Union
5
5
 
6
6
  from agno.models.message import Message
7
7
  from agno.run.agent import RunOutput
8
8
  from agno.run.base import RunStatus
9
+ from agno.run.team import TeamRunOutput
9
10
  from agno.session.summary import SessionSummary
10
11
  from agno.utils.log import log_debug, log_warning
11
12
 
@@ -33,7 +34,7 @@ class AgentSession:
33
34
  # Agent Data: agent_id, name and model
34
35
  agent_data: Optional[Dict[str, Any]] = None
35
36
  # List of all runs in the session
36
- runs: Optional[List[RunOutput]] = None
37
+ runs: Optional[List[Union[RunOutput, TeamRunOutput]]] = None
37
38
  # Summary of the session
38
39
  summary: Optional["SessionSummary"] = None
39
40
 
@@ -57,9 +58,13 @@ class AgentSession:
57
58
  return None
58
59
 
59
60
  runs = data.get("runs")
60
- serialized_runs: List[RunOutput] = []
61
+ serialized_runs: List[Union[RunOutput, TeamRunOutput]] = []
61
62
  if runs is not None and isinstance(runs[0], dict):
62
- serialized_runs = [RunOutput.from_dict(run) for run in runs]
63
+ for run in runs:
64
+ if "agent_id" in run:
65
+ serialized_runs.append(RunOutput.from_dict(run))
66
+ elif "team_id" in run:
67
+ serialized_runs.append(TeamRunOutput.from_dict(run))
63
68
 
64
69
  summary = data.get("summary")
65
70
  if summary is not None and isinstance(summary, dict):
@@ -101,7 +106,7 @@ class AgentSession:
101
106
 
102
107
  log_debug("Added RunOutput to Agent Session")
103
108
 
104
- def get_run(self, run_id: str) -> Optional[RunOutput]:
109
+ def get_run(self, run_id: str) -> Optional[Union[RunOutput, TeamRunOutput]]:
105
110
  for run in self.runs or []:
106
111
  if run.run_id == run_id:
107
112
  return run
agno/team/team.py CHANGED
@@ -36,6 +36,7 @@ from agno.exceptions import (
36
36
  OutputCheckError,
37
37
  RunCancelledException,
38
38
  )
39
+ from agno.filters import FilterExpr
39
40
  from agno.guardrails import BaseGuardrail
40
41
  from agno.knowledge.knowledge import Knowledge
41
42
  from agno.knowledge.types import KnowledgeFilter
@@ -288,7 +289,7 @@ class Team:
288
289
  # --- Agent Knowledge ---
289
290
  knowledge: Optional[Knowledge] = None
290
291
  # Add knowledge_filters to the Agent class attributes
291
- knowledge_filters: Optional[Dict[str, Any]] = None
292
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None
292
293
  # Let the agent choose the knowledge filters
293
294
  enable_agentic_knowledge_filters: Optional[bool] = False
294
295
  # Add a tool that allows the Team to update Knowledge.
@@ -474,7 +475,7 @@ class Team:
474
475
  dependencies: Optional[Dict[str, Any]] = None,
475
476
  add_dependencies_to_context: bool = False,
476
477
  knowledge: Optional[Knowledge] = None,
477
- knowledge_filters: Optional[Dict[str, Any]] = None,
478
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
478
479
  add_knowledge_to_context: bool = False,
479
480
  enable_agentic_knowledge_filters: Optional[bool] = False,
480
481
  update_knowledge: bool = False,
@@ -1742,7 +1743,7 @@ class Team:
1742
1743
  images: Optional[Sequence[Image]] = None,
1743
1744
  videos: Optional[Sequence[Video]] = None,
1744
1745
  files: Optional[Sequence[File]] = None,
1745
- knowledge_filters: Optional[Dict[str, Any]] = None,
1746
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
1746
1747
  add_history_to_context: Optional[bool] = None,
1747
1748
  add_dependencies_to_context: Optional[bool] = None,
1748
1749
  add_session_state_to_context: Optional[bool] = None,
@@ -1769,7 +1770,7 @@ class Team:
1769
1770
  images: Optional[Sequence[Image]] = None,
1770
1771
  videos: Optional[Sequence[Video]] = None,
1771
1772
  files: Optional[Sequence[File]] = None,
1772
- knowledge_filters: Optional[Dict[str, Any]] = None,
1773
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
1773
1774
  add_history_to_context: Optional[bool] = None,
1774
1775
  add_dependencies_to_context: Optional[bool] = None,
1775
1776
  add_session_state_to_context: Optional[bool] = None,
@@ -1797,7 +1798,7 @@ class Team:
1797
1798
  images: Optional[Sequence[Image]] = None,
1798
1799
  videos: Optional[Sequence[Video]] = None,
1799
1800
  files: Optional[Sequence[File]] = None,
1800
- knowledge_filters: Optional[Dict[str, Any]] = None,
1801
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
1801
1802
  add_history_to_context: Optional[bool] = None,
1802
1803
  add_dependencies_to_context: Optional[bool] = None,
1803
1804
  add_session_state_to_context: Optional[bool] = None,
@@ -2602,7 +2603,7 @@ class Team:
2602
2603
  images: Optional[Sequence[Image]] = None,
2603
2604
  videos: Optional[Sequence[Video]] = None,
2604
2605
  files: Optional[Sequence[File]] = None,
2605
- knowledge_filters: Optional[Dict[str, Any]] = None,
2606
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
2606
2607
  add_history_to_context: Optional[bool] = None,
2607
2608
  add_dependencies_to_context: Optional[bool] = None,
2608
2609
  add_session_state_to_context: Optional[bool] = None,
@@ -2629,7 +2630,7 @@ class Team:
2629
2630
  images: Optional[Sequence[Image]] = None,
2630
2631
  videos: Optional[Sequence[Video]] = None,
2631
2632
  files: Optional[Sequence[File]] = None,
2632
- knowledge_filters: Optional[Dict[str, Any]] = None,
2633
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
2633
2634
  add_history_to_context: Optional[bool] = None,
2634
2635
  add_dependencies_to_context: Optional[bool] = None,
2635
2636
  add_session_state_to_context: Optional[bool] = None,
@@ -2657,7 +2658,7 @@ class Team:
2657
2658
  images: Optional[Sequence[Image]] = None,
2658
2659
  videos: Optional[Sequence[Video]] = None,
2659
2660
  files: Optional[Sequence[File]] = None,
2660
- knowledge_filters: Optional[Dict[str, Any]] = None,
2661
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
2661
2662
  add_history_to_context: Optional[bool] = None,
2662
2663
  add_dependencies_to_context: Optional[bool] = None,
2663
2664
  add_session_state_to_context: Optional[bool] = None,
@@ -3872,7 +3873,7 @@ class Team:
3872
3873
  videos: Optional[Sequence[Video]] = None,
3873
3874
  files: Optional[Sequence[File]] = None,
3874
3875
  markdown: Optional[bool] = None,
3875
- knowledge_filters: Optional[Dict[str, Any]] = None,
3876
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
3876
3877
  add_history_to_context: Optional[bool] = None,
3877
3878
  add_dependencies_to_context: Optional[bool] = None,
3878
3879
  add_session_state_to_context: Optional[bool] = None,
@@ -3982,7 +3983,7 @@ class Team:
3982
3983
  videos: Optional[Sequence[Video]] = None,
3983
3984
  files: Optional[Sequence[File]] = None,
3984
3985
  markdown: Optional[bool] = None,
3985
- knowledge_filters: Optional[Dict[str, Any]] = None,
3986
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
3986
3987
  add_history_to_context: Optional[bool] = None,
3987
3988
  dependencies: Optional[Dict[str, Any]] = None,
3988
3989
  add_dependencies_to_context: Optional[bool] = None,
@@ -4271,7 +4272,7 @@ class Team:
4271
4272
  """Calculate session metrics"""
4272
4273
 
4273
4274
  session_messages: List[Message] = []
4274
- for run in session.runs: # type: ignore
4275
+ for run in session.runs or []:
4275
4276
  if run.messages is not None:
4276
4277
  for m in run.messages:
4277
4278
  # Skipping messages from history to avoid duplicates
@@ -7144,7 +7145,7 @@ class Team:
7144
7145
 
7145
7146
  # Yield the member event directly
7146
7147
  member_agent_run_response_event.parent_run_id = (
7147
- member_agent_run_response_event.parent_run_id or run_response.run_id
7148
+ getattr(member_agent_run_response_event, 'parent_run_id', None) or run_response.run_id
7148
7149
  )
7149
7150
  yield member_agent_run_response_event
7150
7151
  else:
@@ -7519,6 +7520,9 @@ class Team:
7519
7520
  session = self.db.get_session(session_id=session_id, session_type=session_type)
7520
7521
  return session # type: ignore
7521
7522
  except Exception as e:
7523
+ import traceback
7524
+
7525
+ traceback.print_exc(limit=3)
7522
7526
  log_warning(f"Error getting session from db: {e}")
7523
7527
  return None
7524
7528
 
@@ -7533,6 +7537,9 @@ class Team:
7533
7537
  session = await self.db.get_session(session_id=session_id, session_type=session_type)
7534
7538
  return session # type: ignore
7535
7539
  except Exception as e:
7540
+ import traceback
7541
+
7542
+ traceback.print_exc(limit=3)
7536
7543
  log_warning(f"Error getting session from db: {e}")
7537
7544
  return None
7538
7545
 
@@ -7544,6 +7551,9 @@ class Team:
7544
7551
  raise ValueError("Db not initialized")
7545
7552
  return self.db.upsert_session(session=session) # type: ignore
7546
7553
  except Exception as e:
7554
+ import traceback
7555
+
7556
+ traceback.print_exc(limit=3)
7547
7557
  log_warning(f"Error upserting session into db: {e}")
7548
7558
  return None
7549
7559
 
@@ -7555,6 +7565,9 @@ class Team:
7555
7565
  raise ValueError("Db not initialized")
7556
7566
  return await self.db.upsert_session(session=session) # type: ignore
7557
7567
  except Exception as e:
7568
+ import traceback
7569
+
7570
+ traceback.print_exc(limit=3)
7558
7571
  log_warning(f"Error upserting session into db: {e}")
7559
7572
  return None
7560
7573
 
@@ -8379,7 +8392,11 @@ class Team:
8379
8392
  return "Successfully added to knowledge base"
8380
8393
 
8381
8394
  def get_relevant_docs_from_knowledge(
8382
- self, query: str, num_documents: Optional[int] = None, filters: Optional[Dict[str, Any]] = None, **kwargs
8395
+ self,
8396
+ query: str,
8397
+ num_documents: Optional[int] = None,
8398
+ filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
8399
+ **kwargs,
8383
8400
  ) -> Optional[List[Union[Dict[str, Any], str]]]:
8384
8401
  """Return a list of references from the knowledge base"""
8385
8402
  from agno.knowledge.document import Document
@@ -8402,6 +8419,10 @@ class Team:
8402
8419
  if not filters:
8403
8420
  log_warning("No valid filters remain after validation. Search will proceed without filters.")
8404
8421
 
8422
+ if invalid_keys == [] and valid_filters == {}:
8423
+ log_warning("No valid filters provided. Search will proceed without filters.")
8424
+ filters = None
8425
+
8405
8426
  if self.knowledge_retriever is not None and callable(self.knowledge_retriever):
8406
8427
  from inspect import signature
8407
8428
 
@@ -8439,7 +8460,11 @@ class Team:
8439
8460
  raise e
8440
8461
 
8441
8462
  async def aget_relevant_docs_from_knowledge(
8442
- self, query: str, num_documents: Optional[int] = None, filters: Optional[Dict[str, Any]] = None, **kwargs
8463
+ self,
8464
+ query: str,
8465
+ num_documents: Optional[int] = None,
8466
+ filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
8467
+ **kwargs,
8443
8468
  ) -> Optional[List[Union[Dict[str, Any], str]]]:
8444
8469
  """Get relevant documents from knowledge base asynchronously."""
8445
8470
  from agno.knowledge.document import Document
@@ -8463,6 +8488,10 @@ class Team:
8463
8488
  if not filters:
8464
8489
  log_warning("No valid filters remain after validation. Search will proceed without filters.")
8465
8490
 
8491
+ if invalid_keys == [] and valid_filters == {}:
8492
+ log_warning("No valid filters provided. Search will proceed without filters.")
8493
+ filters = None
8494
+
8466
8495
  if self.knowledge_retriever is not None and callable(self.knowledge_retriever):
8467
8496
  from inspect import isawaitable, signature
8468
8497
 
@@ -8519,7 +8548,9 @@ class Team:
8519
8548
 
8520
8549
  return json.dumps(docs, indent=2)
8521
8550
 
8522
- def _get_effective_filters(self, knowledge_filters: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
8551
+ def _get_effective_filters(
8552
+ self, knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None
8553
+ ) -> Optional[Any]:
8523
8554
  """
8524
8555
  Determine effective filters for the team, considering:
8525
8556
  1. Team-level filters (self.knowledge_filters)
@@ -8536,7 +8567,15 @@ class Team:
8536
8567
  # Apply run-time filters if they exist
8537
8568
  if knowledge_filters:
8538
8569
  if effective_filters:
8539
- effective_filters.update(knowledge_filters)
8570
+ if isinstance(effective_filters, dict):
8571
+ if isinstance(knowledge_filters, dict):
8572
+ effective_filters.update(cast(Dict[str, Any], knowledge_filters))
8573
+ else:
8574
+ # If knowledge_filters is not a dict (e.g., list of FilterExpr), combine as list if effective_filters is dict
8575
+ # Convert the dict to a list and concatenate
8576
+ effective_filters = cast(Any, [effective_filters, *knowledge_filters])
8577
+ else:
8578
+ effective_filters = [*effective_filters, *knowledge_filters]
8540
8579
  else:
8541
8580
  effective_filters = knowledge_filters
8542
8581
 
@@ -8545,7 +8584,7 @@ class Team:
8545
8584
  def _get_search_knowledge_base_function(
8546
8585
  self,
8547
8586
  run_response: TeamRunOutput,
8548
- knowledge_filters: Optional[Dict[str, Any]] = None,
8587
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
8549
8588
  async_mode: bool = False,
8550
8589
  ) -> Function:
8551
8590
  """Factory function to create a search_knowledge_base function with filters."""
@@ -8614,7 +8653,7 @@ class Team:
8614
8653
  def _get_search_knowledge_base_with_agentic_filters_function(
8615
8654
  self,
8616
8655
  run_response: TeamRunOutput,
8617
- knowledge_filters: Optional[Dict[str, Any]] = None,
8656
+ knowledge_filters: Optional[Union[Dict[str, Any], List[FilterExpr]]] = None,
8618
8657
  async_mode: bool = False,
8619
8658
  ) -> Function:
8620
8659
  """Factory function to create a search_knowledge_base function with filters."""
@@ -116,7 +116,7 @@ class FileGenerationTools(Toolkit):
116
116
  file_type="json",
117
117
  filename=filename,
118
118
  size=len(json_content.encode("utf-8")),
119
- url=f"file://{file_path}" if file_path else None,
119
+ filepath=file_path if file_path else None,
120
120
  )
121
121
 
122
122
  log_debug("JSON file generated successfully")
@@ -203,7 +203,7 @@ class FileGenerationTools(Toolkit):
203
203
  file_type="csv",
204
204
  filename=filename,
205
205
  size=len(csv_content.encode("utf-8")),
206
- url=f"file://{file_path}" if file_path else None,
206
+ filepath=file_path if file_path else None,
207
207
  )
208
208
 
209
209
  log_debug("CSV file generated successfully")
@@ -287,7 +287,7 @@ class FileGenerationTools(Toolkit):
287
287
  file_type="pdf",
288
288
  filename=filename,
289
289
  size=len(pdf_content),
290
- url=f"file://{file_path}" if file_path else None,
290
+ filepath=file_path if file_path else None,
291
291
  )
292
292
 
293
293
  log_debug("PDF file generated successfully")
@@ -333,7 +333,7 @@ class FileGenerationTools(Toolkit):
333
333
  file_type="txt",
334
334
  filename=filename,
335
335
  size=len(content.encode("utf-8")),
336
- url=f"file://{file_path}" if file_path else None,
336
+ filepath=file_path if file_path else None,
337
337
  )
338
338
 
339
339
  log_debug("Text file generated successfully")
agno/tools/gmail.py CHANGED
@@ -141,6 +141,11 @@ class GmailTools(Toolkit):
141
141
  self.create_draft_email,
142
142
  self.send_email,
143
143
  self.send_email_reply,
144
+ # Label management
145
+ self.list_custom_labels,
146
+ self.apply_label,
147
+ self.remove_label,
148
+ self.delete_custom_label,
144
149
  ]
145
150
 
146
151
  super().__init__(name="gmail_tools", tools=tools, **kwargs)
@@ -161,6 +166,7 @@ class GmailTools(Toolkit):
161
166
  "get_emails_by_date",
162
167
  "get_emails_by_thread",
163
168
  "search_emails",
169
+ "list_custom_labels",
164
170
  ]
165
171
  modify_operations = ["mark_email_as_read", "mark_email_as_unread"]
166
172
  if any(read_operation in self.functions for read_operation in read_operations):
@@ -600,6 +606,179 @@ class GmailTools(Toolkit):
600
606
  except Exception as error:
601
607
  return f"Error marking email {message_id} as unread: {type(error).__name__}: {error}"
602
608
 
609
+ @authenticate
610
+ def list_custom_labels(self) -> str:
611
+ """
612
+ List only user-created custom labels (filters out system labels) in a numbered format.
613
+
614
+ Returns:
615
+ str: A numbered list of custom labels only
616
+ """
617
+ try:
618
+ results = self.service.users().labels().list(userId="me").execute() # type: ignore
619
+ labels = results.get("labels", [])
620
+
621
+ # Filter out only user-created labels
622
+ custom_labels = [label["name"] for label in labels if label.get("type") == "user"]
623
+
624
+ if not custom_labels:
625
+ return "No custom labels found.\nCreate labels using apply_label function!"
626
+
627
+ # Create numbered list
628
+ numbered_labels = [f"{i}. {name}" for i, name in enumerate(custom_labels, 1)]
629
+ return f"Your Custom Labels ({len(custom_labels)} total):\n\n" + "\n".join(numbered_labels)
630
+
631
+ except HttpError as e:
632
+ return f"Error fetching labels: {e}"
633
+ except Exception as e:
634
+ return f"Unexpected error: {type(e).__name__}: {e}"
635
+
636
+ @authenticate
637
+ def apply_label(self, context: str, label_name: str, count: int = 10) -> str:
638
+ """
639
+ Find emails matching a context (search query) and apply a label, creating it if necessary.
640
+
641
+ Args:
642
+ context (str): Gmail search query (e.g., 'is:unread category:promotions')
643
+ label_name (str): Name of the label to apply
644
+ count (int): Maximum number of emails to process
645
+ Returns:
646
+ str: Summary of labeled emails
647
+ """
648
+ try:
649
+ # Fetch messages matching context
650
+ results = self.service.users().messages().list(userId="me", q=context, maxResults=count).execute() # type: ignore
651
+
652
+ messages = results.get("messages", [])
653
+ if not messages:
654
+ return f"No emails found matching: '{context}'"
655
+
656
+ # Check if label exists, create if not
657
+ labels = self.service.users().labels().list(userId="me").execute().get("labels", []) # type: ignore
658
+ label_id = None
659
+ for label in labels:
660
+ if label["name"].lower() == label_name.lower():
661
+ label_id = label["id"]
662
+ break
663
+
664
+ if not label_id:
665
+ label = (
666
+ self.service.users() # type: ignore
667
+ .labels()
668
+ .create(
669
+ userId="me",
670
+ body={"name": label_name, "labelListVisibility": "labelShow", "messageListVisibility": "show"},
671
+ )
672
+ .execute()
673
+ )
674
+ label_id = label["id"]
675
+
676
+ # Apply label to all matching messages
677
+ for msg in messages:
678
+ self.service.users().messages().modify( # type: ignore
679
+ userId="me", id=msg["id"], body={"addLabelIds": [label_id]}
680
+ ).execute() # type: ignore
681
+
682
+ return f"Applied label '{label_name}' to {len(messages)} emails matching '{context}'."
683
+
684
+ except HttpError as e:
685
+ return f"Error applying label '{label_name}': {e}"
686
+ except Exception as e:
687
+ return f"Unexpected error: {type(e).__name__}: {e}"
688
+
689
+ @authenticate
690
+ def remove_label(self, context: str, label_name: str, count: int = 10) -> str:
691
+ """
692
+ Remove a label from emails matching a context (search query).
693
+
694
+ Args:
695
+ context (str): Gmail search query (e.g., 'is:unread category:promotions')
696
+ label_name (str): Name of the label to remove
697
+ count (int): Maximum number of emails to process
698
+ Returns:
699
+ str: Summary of emails with label removed
700
+ """
701
+ try:
702
+ # Get all labels to find the target label
703
+ labels = self.service.users().labels().list(userId="me").execute().get("labels", []) # type: ignore
704
+ label_id = None
705
+
706
+ for label in labels:
707
+ if label["name"].lower() == label_name.lower():
708
+ label_id = label["id"]
709
+ break
710
+
711
+ if not label_id:
712
+ return f"Label '{label_name}' not found."
713
+
714
+ # Fetch messages matching context that have this label
715
+ results = (
716
+ self.service.users() # type: ignore
717
+ .messages()
718
+ .list(userId="me", q=f"{context} label:{label_name}", maxResults=count)
719
+ .execute()
720
+ )
721
+
722
+ messages = results.get("messages", [])
723
+ if not messages:
724
+ return f"No emails found matching: '{context}' with label '{label_name}'"
725
+
726
+ # Remove label from all matching messages
727
+ removed_count = 0
728
+ for msg in messages:
729
+ self.service.users().messages().modify( # type: ignore
730
+ userId="me", id=msg["id"], body={"removeLabelIds": [label_id]}
731
+ ).execute() # type: ignore
732
+ removed_count += 1
733
+
734
+ return f"Removed label '{label_name}' from {removed_count} emails matching '{context}'."
735
+
736
+ except HttpError as e:
737
+ return f"Error removing label '{label_name}': {e}"
738
+ except Exception as e:
739
+ return f"Unexpected error: {type(e).__name__}: {e}"
740
+
741
+ @authenticate
742
+ def delete_custom_label(self, label_name: str, confirm: bool = False) -> str:
743
+ """
744
+ Delete a custom label (with safety confirmation).
745
+
746
+ Args:
747
+ label_name (str): Name of the label to delete
748
+ confirm (bool): Must be True to actually delete the label
749
+ Returns:
750
+ str: Confirmation message or warning
751
+ """
752
+ if not confirm:
753
+ return f"LABEL DELETION REQUIRES CONFIRMATION. This will permanently delete the label '{label_name}' from all emails. Set confirm=True to proceed."
754
+
755
+ try:
756
+ # Get all labels to find the target label
757
+ labels = self.service.users().labels().list(userId="me").execute().get("labels", []) # type: ignore
758
+ target_label = None
759
+
760
+ for label in labels:
761
+ if label["name"].lower() == label_name.lower():
762
+ target_label = label
763
+ break
764
+
765
+ if not target_label:
766
+ return f"Label '{label_name}' not found."
767
+
768
+ # Check if it's a system label using the type field
769
+ if target_label.get("type") != "user":
770
+ return f"Cannot delete system label '{label_name}'. Only user-created labels can be deleted."
771
+
772
+ # Delete the label
773
+ self.service.users().labels().delete(userId="me", id=target_label["id"]).execute() # type: ignore
774
+
775
+ return f"Successfully deleted label '{label_name}'. This label has been removed from all emails."
776
+
777
+ except HttpError as e:
778
+ return f"Error deleting label '{label_name}': {e}"
779
+ except Exception as e:
780
+ return f"Unexpected error: {type(e).__name__}: {e}"
781
+
603
782
  def _validate_email_params(self, to: str, subject: str, body: str) -> None:
604
783
  """Validate email parameters."""
605
784
  if not to: