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 +8 -0
- use_agent/__main__.py +4 -0
- use_agent/agent.py +523 -0
- use_agent/auth.py +60 -0
- use_agent/cache.py +81 -0
- use_agent/cli.py +311 -0
- use_agent/config.py +70 -0
- use_agent/gmail.py +500 -0
- use_agent/prompts/classifier.md +215 -0
- use_agent/prompts/reply.md +95 -0
- use_agent/report.py +309 -0
- use_agent/reporter.py +201 -0
- use_agent/settings.py +207 -0
- use_agent/storage.py +179 -0
- use_agent/templates/report.html.j2 +138 -0
- use_agent/tools.py +370 -0
- use_agent-1.0.0.dist-info/METADATA +396 -0
- use_agent-1.0.0.dist-info/RECORD +21 -0
- use_agent-1.0.0.dist-info/WHEEL +4 -0
- use_agent-1.0.0.dist-info/entry_points.txt +2 -0
- use_agent-1.0.0.dist-info/licenses/LICENSE +25 -0
use_agent/__init__.py
ADDED
use_agent/__main__.py
ADDED
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)
|