use-agent 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.
use_agent/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version('use-agent')
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = '0.0.0+unknown'
7
+
8
+ __all__ = ['__version__']
use_agent/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from use_agent.cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
use_agent/agent.py ADDED
@@ -0,0 +1,523 @@
1
+ """Orchestrates a Claude Agent run over the Gmail inbox."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ import claude_agent_sdk
7
+ import jinja2
8
+
9
+ from use_agent import (
10
+ auth,
11
+ config,
12
+ gmail,
13
+ tools,
14
+ )
15
+ from use_agent import (
16
+ cache as cache_mod,
17
+ )
18
+ from use_agent import (
19
+ reporter as reporter_mod,
20
+ )
21
+ from use_agent import (
22
+ settings as settings_mod,
23
+ )
24
+ from use_agent import (
25
+ storage as storage_mod,
26
+ )
27
+
28
+ LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ SYSTEM_PROMPT_TEMPLATE = """\
32
+ You are use-agent, a triage assistant for {{ user_name }}'s Gmail
33
+ inbox. Your job is to find unsolicited sales email ("cold sales"),
34
+ reply to it on {{ user_name }}'s behalf in their voice, and then
35
+ archive the thread. You also identify unsolicited bulk marketing
36
+ (newsletters, product promos) and unsubscribe + delete them.
37
+
38
+ You have the following tools (prefixed with ``mcp__gmail__``):
39
+ - ``search``: run a Gmail search query, returns message ids
40
+ - ``get_message``: fetch full message (headers, body, thread state)
41
+ - ``reply``: send a threaded reply to a message
42
+ - ``archive_and_mark_read``: archive the thread and mark it read
43
+ - ``unsubscribe_and_trash``: honor the message's
44
+ ``List-Unsubscribe`` header (RFC 8058 one-click HTTPS POST when
45
+ available; else HTTPS GET; else a mailto unsubscribe) then Trash
46
+ the thread
47
+ - ``trash``: move the thread to Trash without touching any
48
+ unsubscribe endpoint
49
+ - ``record_action``: persist a message you acted on to the action
50
+ history database (call once per acted-upon message)
51
+
52
+ ## Workflow
53
+
54
+ 1. Call ``search`` with the query provided in the user message.
55
+ 2. For each returned message, call ``get_message``.
56
+ 3. Skip any message where ``thread_replied`` is true or where the
57
+ sender domain is in the safelist below. Skip BULK_MARKETING
58
+ candidates that match the newsletter keep-list below (community
59
+ lists or senders the user opted into).
60
+ 4. Classify per the rules below. Then:
61
+ - `COLD_SALES`: generate a reply body from the reply rules,
62
+ call ``reply``, then ``archive_and_mark_read`` on success.
63
+ - `BULK_MARKETING` with ``response_mode=unsubscribe_and_delete``:
64
+ call ``unsubscribe_and_trash``, passing ``thread_id`` plus the
65
+ ``list_unsubscribe`` and ``list_unsubscribe_post`` header
66
+ values from ``get_message``. Do not send a reply.
67
+ - `BULK_MARKETING` with ``response_mode=delete``: call
68
+ ``trash``. Do not send a reply and do not click any in-body
69
+ unsubscribe link — clicking validates the address to the
70
+ sender.
71
+ - `NOT_COLD_SALES`: take no action.
72
+ 5. After each message you actually acted on (a reply, archive,
73
+ unsubscribe, or trash completed successfully), call
74
+ ``record_action`` once with ``message_id``, ``sender``,
75
+ ``subject``, ``sent_at`` (the ``date`` from ``get_message``),
76
+ ``classification``, ``category``, ``response_mode``,
77
+ ``action_taken``, and ``score``. Do NOT call it for skipped
78
+ messages, and do NOT call it when ``dry_run`` is true.
79
+ 6. If ``dry_run`` is true, pass ``dry_run=true`` through to
80
+ ``unsubscribe_and_trash`` / ``trash``, and skip ``reply`` +
81
+ ``archive_and_mark_read`` entirely. Still report what you would
82
+ have done.
83
+ 7. When finished, emit a single fenced JSON block (no other JSON
84
+ in your output) with a top-level ``results`` array. One entry
85
+ per examined message; each entry has these keys:
86
+
87
+ - ``message_id``: the Gmail ``message_id`` from ``get_message``
88
+ - ``sender``: the original ``From`` header, including name
89
+ - ``subject``: the original subject
90
+ - ``date``: the original ``Date`` header value from
91
+ ``get_message`` (copy it verbatim)
92
+ - ``classification``: ``COLD_SALES``, ``BULK_MARKETING``, or
93
+ ``NOT_COLD_SALES``
94
+ - ``category``: a 1-5 word label summarizing what the message is
95
+ about (e.g. ``Recruiter``, ``AI Solution``, ``Staff
96
+ Augmentation``, ``SEO Services``, ``Newsletter``). Title Case.
97
+ - ``score``: integer
98
+ - ``response_mode``: ``hard_remove``,
99
+ ``hard_remove_with_correction``, ``specific_decline``,
100
+ ``unsubscribe_and_delete``, ``delete``, or ``none``
101
+ - ``action_taken``: one of ``Reply sent & archived``,
102
+ ``Dry-run: would reply & archive``,
103
+ ``Reply sent & trashed``, ``Dry-run: would reply & trash``,
104
+ ``Unsubscribed & trashed (<method>)``,
105
+ ``Dry-run: would unsubscribe & trash (<method>)``,
106
+ ``Trashed``, ``Dry-run: would trash``,
107
+ ``Skipped (not cold sales)``, ``Skipped (kept: newsletter)``,
108
+ ``Skipped (already replied)``,
109
+ or ``Error: <detail>``
110
+
111
+ Use a ```` ```json ```` fence. The block is parsed by the host
112
+ program, so it must be valid JSON.
113
+
114
+ Never invent or fabricate email content. Never send anything other
115
+ than the reply text produced by the reply rules, or the
116
+ unsubscribe payloads produced by ``unsubscribe_and_trash``. If a
117
+ tool fails, record the error in the corresponding ``action_taken``
118
+ field and continue with the next message.
119
+
120
+ ## Safelist domains
121
+
122
+ {% if safelist_domains %}
123
+ Treat senders from any of these domains as internal. Never classify
124
+ them as cold sales or bulk marketing, and never reply or
125
+ unsubscribe:
126
+
127
+ {% for d in safelist_domains %}
128
+ - {{ d }}
129
+ {% endfor %}
130
+ {% else %}
131
+ (none configured)
132
+ {% endif %}
133
+
134
+ ## Newsletter keep-list
135
+
136
+ {{ newsletter_keep_block }}
137
+
138
+ ## Classification rules
139
+
140
+ {{ classifier }}
141
+
142
+ ## Reply rules
143
+
144
+ {{ reply }}
145
+ """
146
+
147
+
148
+ def _jinja_env() -> jinja2.Environment:
149
+ return jinja2.Environment(
150
+ loader=jinja2.FileSystemLoader(str(config.PROMPTS_DIR)),
151
+ autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
152
+ keep_trailing_newline=True,
153
+ undefined=jinja2.StrictUndefined,
154
+ trim_blocks=True,
155
+ lstrip_blocks=True,
156
+ )
157
+
158
+
159
+ def _render_template(
160
+ env: jinja2.Environment,
161
+ name: str,
162
+ settings: settings_mod.Settings,
163
+ ) -> str:
164
+ template = env.get_template(name)
165
+ return template.render(_render_context(env, settings))
166
+
167
+
168
+ def _render_context(
169
+ env: jinja2.Environment,
170
+ settings: settings_mod.Settings,
171
+ ) -> dict[str, object]:
172
+ base = {
173
+ 'user_name': settings.user_name,
174
+ 'organization': settings.organization,
175
+ 'safelist_domains': list(settings.safelist_domains),
176
+ 'vendor_names': list(settings.vendor_names),
177
+ 'voice_guidelines': list(settings.voice_guidelines),
178
+ 'newsletter_keep_domains': list(settings.newsletter_keep_domains),
179
+ 'newsletter_keep_list_ids': list(settings.newsletter_keep_list_ids),
180
+ }
181
+ # The footer may itself be a Jinja string (e.g. "{{ user_name }}").
182
+ rendered_footer = (
183
+ env.from_string(settings.reply_footer).render(base)
184
+ if settings.reply_footer
185
+ else ''
186
+ )
187
+ voice_block = '\n'.join(f'- {g}' for g in settings.voice_guidelines)
188
+ # Example bullets may themselves contain Jinja refs like
189
+ # {{ organization }}; render each one against the base context
190
+ # before joining so interpolation happens exactly once.
191
+ hard_remove_examples = _render_examples(
192
+ env, settings.examples_hard_remove, base
193
+ )
194
+ hard_remove_with_correction_examples = _render_examples(
195
+ env, settings.examples_hard_remove_with_correction, base
196
+ )
197
+ specific_decline_examples = _render_examples(
198
+ env, settings.examples_specific_decline, base
199
+ )
200
+ # Pre-rendered blocks are inserted verbatim so prompt markdown
201
+ # stays free of Jinja control flow (formatters don't preserve
202
+ # blank lines inside {% if %} blocks).
203
+ if rendered_footer:
204
+ footer_block = f'\n\n{rendered_footer}'
205
+ footer_instruction = (
206
+ 'Every reply must end with the following footer, '
207
+ 'appended after the reply body on a new line, '
208
+ 'separated by a blank line:\n\n'
209
+ f'```\n{rendered_footer}\n```'
210
+ )
211
+ else:
212
+ footer_block = ''
213
+ footer_instruction = (
214
+ 'No footer — send the reply body verbatim with no trailing text.'
215
+ )
216
+ return {
217
+ **base,
218
+ 'reply_footer': rendered_footer,
219
+ 'voice_block': voice_block,
220
+ 'footer_block': footer_block,
221
+ 'footer_instruction': footer_instruction,
222
+ 'hard_remove_examples': hard_remove_examples,
223
+ 'hard_remove_with_correction_examples': (
224
+ hard_remove_with_correction_examples
225
+ ),
226
+ 'specific_decline_examples': specific_decline_examples,
227
+ }
228
+
229
+
230
+ def _render_newsletter_keep_block(
231
+ keep_domains: tuple[str, ...],
232
+ keep_list_ids: tuple[str, ...],
233
+ ) -> str:
234
+ """Pre-render the newsletter keep-list as Markdown.
235
+
236
+ Rendered in Python (not Jinja) so blank lines survive the
237
+ pre-commit Markdown formatter, which collapses them inside
238
+ ``{% if %}`` blocks.
239
+ """
240
+ if not keep_domains and not keep_list_ids:
241
+ return (
242
+ 'No newsletter keep-list configured. Treat every message '
243
+ 'matching BULK_MARKETING signals as bulk marketing.'
244
+ )
245
+ lines: list[str] = [
246
+ 'Do NOT classify a message as BULK_MARKETING if it matches '
247
+ 'either of the following. These are senders the user '
248
+ 'affirmatively opted into.',
249
+ '',
250
+ ]
251
+ if keep_domains:
252
+ lines.append('Kept sender domains:')
253
+ lines.append('')
254
+ lines.extend(f'- `{d}`' for d in keep_domains)
255
+ lines.append('')
256
+ if keep_list_ids:
257
+ lines.append('Kept `List-Id` values:')
258
+ lines.append('')
259
+ lines.extend(f'- `{i}`' for i in keep_list_ids)
260
+ lines.append('')
261
+ lines.append(
262
+ 'When one of these matches, set `classification` to '
263
+ '`NOT_COLD_SALES`, `response_mode` to `none`, and note '
264
+ '"kept: newsletter match" in `notes`.'
265
+ )
266
+ return '\n'.join(lines)
267
+
268
+
269
+ def _render_examples(
270
+ env: jinja2.Environment,
271
+ items: tuple[str, ...],
272
+ context: dict[str, object],
273
+ ) -> str:
274
+ """Render each example as a Markdown bullet.
275
+
276
+ An empty list collapses to the single line "(none configured)"
277
+ so the rendered prompt always has something under the heading.
278
+ """
279
+ if not items:
280
+ return '(none configured)'
281
+ bullets: list[str] = []
282
+ for item in items:
283
+ rendered = env.from_string(item).render(context).strip()
284
+ bullets.append(f'- "{rendered}"')
285
+ return '\n'.join(bullets)
286
+
287
+
288
+ def _render_system_prompt(settings: settings_mod.Settings) -> str:
289
+ env = _jinja_env()
290
+ classifier = _render_template(env, config.CLASSIFIER_PROMPT.name, settings)
291
+ reply = _render_template(env, config.REPLY_PROMPT.name, settings)
292
+ newsletter_keep_block = _render_newsletter_keep_block(
293
+ settings.newsletter_keep_domains,
294
+ settings.newsletter_keep_list_ids,
295
+ )
296
+ system_template = jinja2.Environment(
297
+ autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
298
+ keep_trailing_newline=True,
299
+ undefined=jinja2.StrictUndefined,
300
+ trim_blocks=True,
301
+ lstrip_blocks=True,
302
+ ).from_string(SYSTEM_PROMPT_TEMPLATE)
303
+ return system_template.render(
304
+ user_name=settings.user_name,
305
+ safelist_domains=list(settings.safelist_domains),
306
+ newsletter_keep_block=newsletter_keep_block,
307
+ classifier=classifier.strip(),
308
+ reply=reply.strip(),
309
+ )
310
+
311
+
312
+ def _user_prompt(
313
+ *,
314
+ query: str,
315
+ max_results: int,
316
+ dry_run: bool,
317
+ delete_only: bool,
318
+ delete: bool,
319
+ ) -> str:
320
+ prompt = (
321
+ 'Process the Gmail inbox now.\n\n'
322
+ f'search query: {query}\n'
323
+ f'max_results: {max_results}\n'
324
+ f'dry_run: {str(dry_run).lower()}\n'
325
+ )
326
+ if delete_only:
327
+ prompt += (
328
+ '\nOverride: when ``classification`` is ``COLD_SALES`` or '
329
+ '``BULK_MARKETING``, do NOT call ``reply`` and do NOT call '
330
+ '``unsubscribe_and_trash``. Call ``trash`` instead (passing '
331
+ '``dry_run`` through). Set ``response_mode`` to ``delete`` '
332
+ 'and ``action_taken`` to ``Trashed`` (or '
333
+ '``Dry-run: would trash`` when ``dry_run`` is true).\n'
334
+ )
335
+ elif delete:
336
+ prompt += (
337
+ '\nOverride: after replying to a ``COLD_SALES`` message, '
338
+ 'call ``trash`` instead of ``archive_and_mark_read`` '
339
+ '(passing ``dry_run`` through). Set ``action_taken`` to '
340
+ '``Reply sent & trashed`` (or ``Dry-run: would reply & '
341
+ 'trash`` when ``dry_run`` is true).\n'
342
+ )
343
+ return prompt
344
+
345
+
346
+ async def run(
347
+ *,
348
+ settings: settings_mod.Settings,
349
+ reporter: reporter_mod.Reporter,
350
+ query: str | None = None,
351
+ max_results: int | None = None,
352
+ lookback: str | None = None,
353
+ dry_run: bool = False,
354
+ delete_only: bool = False,
355
+ delete: bool = False,
356
+ ) -> int:
357
+ """Execute a single agent pass over the inbox.
358
+
359
+ Returns a process exit code — 0 on a successful run with a
360
+ parsed summary, non-zero if the agent produced no summary.
361
+ """
362
+ effective_query = query or settings.search_query
363
+ effective_lookback = (
364
+ settings_mod.validate_lookback(lookback)
365
+ if lookback is not None
366
+ else settings.lookback
367
+ )
368
+ if effective_lookback:
369
+ effective_query = f'{effective_query} newer_than:{effective_lookback}'
370
+ effective_max = max_results or settings.max_results
371
+ creds = auth.load_credentials(
372
+ credentials_file=config.credentials_path(),
373
+ token_file=config.token_path(),
374
+ scopes=config.GMAIL_SCOPES,
375
+ )
376
+ client = gmail.GmailClient(creds)
377
+ seen = _load_and_prune_cache(client, _prune_query(effective_query))
378
+ # No history is written on a dry run; the store stays None and the
379
+ # record_action tool no-ops.
380
+ store = (
381
+ None
382
+ if dry_run
383
+ else storage_mod.Store(
384
+ settings.db_path,
385
+ query_target=storage_mod.query_target(effective_query),
386
+ )
387
+ )
388
+ server = tools.build_mcp_server(client, seen, store)
389
+ options = claude_agent_sdk.ClaudeAgentOptions(
390
+ system_prompt=_render_system_prompt(settings),
391
+ mcp_servers={tools.MCP_SERVER_NAME: server},
392
+ allowed_tools=list(tools.ALLOWED_TOOLS),
393
+ permission_mode='acceptEdits',
394
+ model=settings.model,
395
+ # Isolate from the host's Claude Code setup: no user/project
396
+ # settings, no on-disk settings file, no auto-loaded skills.
397
+ # Only our Gmail MCP server and system prompt drive behavior.
398
+ setting_sources=[],
399
+ settings=None,
400
+ skills=None,
401
+ )
402
+ prompt = _user_prompt(
403
+ query=effective_query,
404
+ max_results=effective_max,
405
+ dry_run=dry_run,
406
+ delete_only=delete_only,
407
+ delete=delete,
408
+ )
409
+ LOGGER.debug(
410
+ 'starting agent run: query=%r max=%d dry_run=%s delete_only=%s '
411
+ 'delete=%s model=%s',
412
+ effective_query,
413
+ effective_max,
414
+ dry_run,
415
+ delete_only,
416
+ delete,
417
+ settings.model,
418
+ )
419
+ try:
420
+ async for message in claude_agent_sdk.query(
421
+ prompt=prompt, options=options
422
+ ):
423
+ _forward(message, reporter)
424
+ seen.save()
425
+ rc = reporter.finish()
426
+ if store is not None:
427
+ _reconcile_history(store, reporter.summary)
428
+ return rc
429
+ finally:
430
+ if store is not None:
431
+ store.close()
432
+
433
+
434
+ def _reconcile_history(
435
+ store: storage_mod.Store,
436
+ summary: list[dict[str, object]] | None,
437
+ ) -> None:
438
+ """Insert acted-upon summary rows the record_action tool missed.
439
+
440
+ The tool is the primary capture path, but the model may forget to
441
+ call it. Every acted-upon row in the final JSON summary that isn't
442
+ already recorded this run is inserted, tagged ``source='summary'``.
443
+ """
444
+ if not summary:
445
+ return
446
+ for row in summary:
447
+ action_taken = str(row.get('action_taken', ''))
448
+ if not storage_mod.is_action(action_taken):
449
+ continue
450
+ message_id = str(row.get('message_id', '') or '')
451
+ sender = str(row.get('sender', '') or '')
452
+ subject = str(row.get('subject', '') or '')
453
+ if store.has(message_id=message_id, sender=sender, subject=subject):
454
+ continue
455
+ store.record(
456
+ sender=sender,
457
+ subject=subject,
458
+ sent_at=str(row.get('date', '') or ''),
459
+ classification=str(row.get('classification', '') or ''),
460
+ category=str(row.get('category', '') or ''),
461
+ response_mode=str(row.get('response_mode', '') or ''),
462
+ action_taken=action_taken,
463
+ score=row.get('score'),
464
+ message_id=message_id,
465
+ source='summary',
466
+ )
467
+
468
+
469
+ def _forward(message: object, reporter: reporter_mod.Reporter) -> None:
470
+ """Feed assistant text blocks to the reporter."""
471
+ if isinstance(message, claude_agent_sdk.AssistantMessage):
472
+ for block in message.content:
473
+ if isinstance(block, claude_agent_sdk.TextBlock):
474
+ reporter.on_text(block.text)
475
+
476
+
477
+ _PRUNE_OPERAND_RE = re.compile(r'(?<!-)\b(in|label):(\S+)')
478
+
479
+
480
+ def _prune_query(query: str) -> str:
481
+ """Derive the cache-prune folder scope from a run's query.
482
+
483
+ Extracts the first ``in:<x>`` or ``label:<x>`` operand and
484
+ returns it as a standalone folder query, so pruning lists only
485
+ the folder the run actually targets. Falls back to ``in:inbox``
486
+ when the query names no folder — keeping default inbox runs
487
+ byte-for-byte identical.
488
+ """
489
+ match = _PRUNE_OPERAND_RE.search(query)
490
+ if match is None:
491
+ return 'in:inbox'
492
+ return f'{match.group(1)}:{match.group(2)}'
493
+
494
+
495
+ def _load_and_prune_cache(
496
+ client: gmail.GmailClient,
497
+ prune_query: str = 'in:inbox',
498
+ ) -> cache_mod.Cache:
499
+ """Load the seen-message cache and drop entries no longer present.
500
+
501
+ ``prune_query`` scopes the listing to the folder the run targets
502
+ (e.g. ``in:inbox`` or ``in:spam``); cached ids outside that folder
503
+ are pruned. A listing failure (network error, auth hiccup,
504
+ pagination cap) degrades gracefully: the cache is left untouched
505
+ rather than wiped, so a transient failure can't force
506
+ re-investigation of every cached message.
507
+ """
508
+ seen = cache_mod.Cache.load(config.cache_path())
509
+ try:
510
+ current_ids = client.list_message_ids(prune_query)
511
+ except Exception:
512
+ LOGGER.exception('failed to list messages; skipping cache prune')
513
+ current_ids = None
514
+ if current_ids is not None:
515
+ dropped = seen.retain(current_ids)
516
+ if dropped:
517
+ LOGGER.debug(
518
+ 'pruned %d cache entries no longer in %s',
519
+ dropped,
520
+ prune_query,
521
+ )
522
+ LOGGER.debug('seen-message cache: %d entries', len(seen))
523
+ return seen
use_agent/auth.py ADDED
@@ -0,0 +1,60 @@
1
+ """Google OAuth installed-app flow for Gmail."""
2
+
3
+ import logging
4
+ import pathlib
5
+
6
+ import google.auth.transport.requests
7
+ import google.oauth2.credentials
8
+ import google_auth_oauthlib.flow
9
+
10
+ LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ def load_credentials(
14
+ *,
15
+ credentials_file: pathlib.Path,
16
+ token_file: pathlib.Path,
17
+ scopes: tuple[str, ...],
18
+ ) -> google.oauth2.credentials.Credentials:
19
+ """Return valid Credentials, running the OAuth flow if needed.
20
+
21
+ If a token file exists, load and refresh it. Otherwise, run the
22
+ installed-app browser flow using ``credentials_file`` and persist
23
+ the resulting token.
24
+ """
25
+ creds: google.oauth2.credentials.Credentials | None = None
26
+ if token_file.exists():
27
+ creds = (
28
+ google.oauth2.credentials.Credentials.from_authorized_user_file(
29
+ str(token_file), list(scopes)
30
+ )
31
+ )
32
+ if creds and creds.valid:
33
+ return creds
34
+ if creds and creds.expired and creds.refresh_token:
35
+ LOGGER.info('refreshing Gmail OAuth token')
36
+ creds.refresh(google.auth.transport.requests.Request())
37
+ _write_token(token_file, creds)
38
+ return creds
39
+ if not credentials_file.exists():
40
+ raise FileNotFoundError(
41
+ f'OAuth client secret not found at {credentials_file}. '
42
+ 'Create one in Google Cloud Console (Desktop app) and '
43
+ 'place it there, or set USE_AGENT_CREDENTIALS.'
44
+ )
45
+ LOGGER.info('running OAuth installed-app flow')
46
+ flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
47
+ str(credentials_file), list(scopes)
48
+ )
49
+ creds = flow.run_local_server(port=0)
50
+ _write_token(token_file, creds)
51
+ return creds
52
+
53
+
54
+ def _write_token(
55
+ path: pathlib.Path,
56
+ creds: google.oauth2.credentials.Credentials,
57
+ ) -> None:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(creds.to_json())
60
+ path.chmod(0o600)
use_agent/cache.py ADDED
@@ -0,0 +1,81 @@
1
+ """JSON-backed cache of Gmail message_ids already processed.
2
+
3
+ Entries live in the cache until the underlying message leaves the
4
+ Gmail inbox, at which point :meth:`Cache.retain` drops them. The
5
+ ``search`` tool consults this cache so the agent never wastes a turn
6
+ re-investigating a message it has already examined.
7
+ """
8
+
9
+ import dataclasses
10
+ import datetime
11
+ import json
12
+ import logging
13
+ import pathlib
14
+ import typing
15
+
16
+ LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclasses.dataclass(slots=True)
20
+ class Cache:
21
+ """Set of seen Gmail message_ids, persisted as JSON on disk."""
22
+
23
+ path: pathlib.Path
24
+ _entries: dict[str, str] = dataclasses.field(default_factory=dict)
25
+
26
+ @classmethod
27
+ def load(cls, path: pathlib.Path) -> typing.Self:
28
+ """Load the cache at ``path``.
29
+
30
+ A missing, empty, or corrupt file yields an empty cache —
31
+ correctness is preserved (missed entries just cause the next
32
+ run to re-examine a message, never to double-send a reply).
33
+ """
34
+ instance = cls(path=path)
35
+ if not path.exists():
36
+ return instance
37
+ try:
38
+ raw = path.read_text(encoding='utf-8')
39
+ data = json.loads(raw) if raw.strip() else {}
40
+ except OSError, json.JSONDecodeError:
41
+ LOGGER.warning('cache at %s is unreadable; starting empty', path)
42
+ return instance
43
+ if isinstance(data, dict):
44
+ instance._entries = {
45
+ str(k): str(v) for k, v in data.items() if isinstance(k, str)
46
+ }
47
+ return instance
48
+
49
+ def save(self) -> None:
50
+ """Write the cache to :attr:`path` (creates parents as needed)."""
51
+ self.path.parent.mkdir(parents=True, exist_ok=True)
52
+ self.path.write_text(
53
+ json.dumps(self._entries, indent=2, sort_keys=True),
54
+ encoding='utf-8',
55
+ )
56
+
57
+ def __contains__(self, message_id: object) -> bool:
58
+ return isinstance(message_id, str) and message_id in self._entries
59
+
60
+ def __len__(self) -> int:
61
+ return len(self._entries)
62
+
63
+ def add(self, message_id: str) -> None:
64
+ """Mark ``message_id`` as seen. Idempotent — re-adds are no-ops."""
65
+ if message_id not in self._entries:
66
+ self._entries[message_id] = datetime.datetime.now(
67
+ datetime.UTC
68
+ ).isoformat()
69
+
70
+ def retain(self, keep: typing.Iterable[str]) -> int:
71
+ """Drop entries whose id is not in ``keep``.
72
+
73
+ Returns the number of entries dropped. Used at run start to
74
+ evict message_ids no longer present in the inbox.
75
+ """
76
+ keep_set = set(keep)
77
+ before = len(self._entries)
78
+ self._entries = {
79
+ k: v for k, v in self._entries.items() if k in keep_set
80
+ }
81
+ return before - len(self._entries)