use-agent 1.0.0b1__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,281 @@
1
+ """Orchestrates a Claude Agent run over the Gmail inbox."""
2
+
3
+ import logging
4
+
5
+ import claude_agent_sdk
6
+ import jinja2
7
+
8
+ from use_agent import (
9
+ auth,
10
+ config,
11
+ gmail,
12
+ tools,
13
+ )
14
+ from use_agent import (
15
+ reporter as reporter_mod,
16
+ )
17
+ from use_agent import (
18
+ settings as settings_mod,
19
+ )
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+
23
+
24
+ SYSTEM_PROMPT_TEMPLATE = """\
25
+ You are use-agent, a triage assistant for {{ user_name }}'s Gmail
26
+ inbox. Your job is to find unsolicited sales email ("cold sales"),
27
+ reply to it on {{ user_name }}'s behalf in their voice, and then
28
+ archive the thread.
29
+
30
+ You have the following tools (prefixed with ``mcp__gmail__``):
31
+ - ``search``: run a Gmail search query, returns message ids
32
+ - ``get_message``: fetch full message (headers, body, thread state)
33
+ - ``reply``: send a threaded reply to a message
34
+ - ``archive_and_mark_read``: archive the thread and mark it read
35
+
36
+ ## Workflow
37
+
38
+ 1. Call ``search`` with the query provided in the user message.
39
+ 2. For each returned message, call ``get_message``.
40
+ 3. Skip any message where ``thread_replied`` is true, where the
41
+ sender domain is in the safelist below, or where the classifier
42
+ result is not COLD_SALES.
43
+ 4. For each COLD_SALES message, generate a reply body using the
44
+ reply rules below, then call ``reply`` with that body. On
45
+ success, call ``archive_and_mark_read``.
46
+ 5. If ``dry_run`` is true, do everything except call ``reply`` and
47
+ ``archive_and_mark_read``. Still report what you would have done.
48
+ 6. When finished, emit a single fenced JSON block (no other JSON
49
+ in your output) with a top-level ``results`` array. One entry
50
+ per examined message; each entry has these keys:
51
+
52
+ - ``sender``: the original ``From`` header, including name
53
+ - ``subject``: the original subject
54
+ - ``classification``: ``COLD_SALES`` or ``NOT_COLD_SALES``
55
+ - ``score``: integer
56
+ - ``response_mode``: ``hard_remove``,
57
+ ``hard_remove_with_correction``, ``specific_decline``, or
58
+ ``none``
59
+ - ``action_taken``: one of ``Reply sent & archived``,
60
+ ``Dry-run: would reply & archive``,
61
+ ``Skipped (not cold sales)``, ``Skipped (already replied)``,
62
+ or ``Error: <detail>``
63
+
64
+ Use a ```` ```json ```` fence. The block is parsed by the host
65
+ program, so it must be valid JSON.
66
+
67
+ Never invent or fabricate email content. Never send anything other
68
+ than the reply text produced by the reply rules. If a tool fails,
69
+ record the error in the corresponding ``action_taken`` field and
70
+ continue with the next message.
71
+
72
+ ## Safelist domains
73
+
74
+ {% if safelist_domains %}
75
+ Treat senders from any of these domains as internal. Never classify
76
+ them as cold sales and never reply:
77
+
78
+ {% for d in safelist_domains %}
79
+ - {{ d }}
80
+ {% endfor %}
81
+ {% else %}
82
+ (none configured)
83
+ {% endif %}
84
+
85
+ ## Classification rules
86
+
87
+ {{ classifier }}
88
+
89
+ ## Reply rules
90
+
91
+ {{ reply }}
92
+ """
93
+
94
+
95
+ def _jinja_env() -> jinja2.Environment:
96
+ return jinja2.Environment(
97
+ loader=jinja2.FileSystemLoader(str(config.PROMPTS_DIR)),
98
+ autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
99
+ keep_trailing_newline=True,
100
+ undefined=jinja2.StrictUndefined,
101
+ trim_blocks=True,
102
+ lstrip_blocks=True,
103
+ )
104
+
105
+
106
+ def _render_template(
107
+ env: jinja2.Environment,
108
+ name: str,
109
+ settings: settings_mod.Settings,
110
+ ) -> str:
111
+ template = env.get_template(name)
112
+ return template.render(_render_context(env, settings))
113
+
114
+
115
+ def _render_context(
116
+ env: jinja2.Environment,
117
+ settings: settings_mod.Settings,
118
+ ) -> dict[str, object]:
119
+ base = {
120
+ 'user_name': settings.user_name,
121
+ 'organization': settings.organization,
122
+ 'safelist_domains': list(settings.safelist_domains),
123
+ 'vendor_names': list(settings.vendor_names),
124
+ 'voice_guidelines': list(settings.voice_guidelines),
125
+ }
126
+ # The footer may itself be a Jinja string (e.g. "{{ user_name }}").
127
+ rendered_footer = (
128
+ env.from_string(settings.reply_footer).render(base)
129
+ if settings.reply_footer
130
+ else ''
131
+ )
132
+ voice_block = '\n'.join(f'- {g}' for g in settings.voice_guidelines)
133
+ # Example bullets may themselves contain Jinja refs like
134
+ # {{ organization }}; render each one against the base context
135
+ # before joining so interpolation happens exactly once.
136
+ hard_remove_examples = _render_examples(
137
+ env, settings.examples_hard_remove, base
138
+ )
139
+ hard_remove_with_correction_examples = _render_examples(
140
+ env, settings.examples_hard_remove_with_correction, base
141
+ )
142
+ specific_decline_examples = _render_examples(
143
+ env, settings.examples_specific_decline, base
144
+ )
145
+ # Pre-rendered blocks are inserted verbatim so prompt markdown
146
+ # stays free of Jinja control flow (formatters don't preserve
147
+ # blank lines inside {% if %} blocks).
148
+ if rendered_footer:
149
+ footer_block = f'\n\n{rendered_footer}'
150
+ footer_instruction = (
151
+ 'Every reply must end with the following footer, '
152
+ 'appended after the reply body on a new line, '
153
+ 'separated by a blank line:\n\n'
154
+ f'```\n{rendered_footer}\n```'
155
+ )
156
+ else:
157
+ footer_block = ''
158
+ footer_instruction = (
159
+ 'No footer — send the reply body verbatim with no trailing text.'
160
+ )
161
+ return {
162
+ **base,
163
+ 'reply_footer': rendered_footer,
164
+ 'voice_block': voice_block,
165
+ 'footer_block': footer_block,
166
+ 'footer_instruction': footer_instruction,
167
+ 'hard_remove_examples': hard_remove_examples,
168
+ 'hard_remove_with_correction_examples': (
169
+ hard_remove_with_correction_examples
170
+ ),
171
+ 'specific_decline_examples': specific_decline_examples,
172
+ }
173
+
174
+
175
+ def _render_examples(
176
+ env: jinja2.Environment,
177
+ items: tuple[str, ...],
178
+ context: dict[str, object],
179
+ ) -> str:
180
+ """Render each example as a Markdown bullet.
181
+
182
+ An empty list collapses to the single line "(none configured)"
183
+ so the rendered prompt always has something under the heading.
184
+ """
185
+ if not items:
186
+ return '(none configured)'
187
+ bullets: list[str] = []
188
+ for item in items:
189
+ rendered = env.from_string(item).render(context).strip()
190
+ bullets.append(f'- "{rendered}"')
191
+ return '\n'.join(bullets)
192
+
193
+
194
+ def _render_system_prompt(settings: settings_mod.Settings) -> str:
195
+ env = _jinja_env()
196
+ classifier = _render_template(env, config.CLASSIFIER_PROMPT.name, settings)
197
+ reply = _render_template(env, config.REPLY_PROMPT.name, settings)
198
+ system_template = jinja2.Environment(
199
+ autoescape=False, # noqa: S701 - prompts are fed to an LLM, not rendered as HTML
200
+ keep_trailing_newline=True,
201
+ undefined=jinja2.StrictUndefined,
202
+ trim_blocks=True,
203
+ lstrip_blocks=True,
204
+ ).from_string(SYSTEM_PROMPT_TEMPLATE)
205
+ return system_template.render(
206
+ user_name=settings.user_name,
207
+ safelist_domains=list(settings.safelist_domains),
208
+ classifier=classifier.strip(),
209
+ reply=reply.strip(),
210
+ )
211
+
212
+
213
+ def _user_prompt(*, query: str, max_results: int, dry_run: bool) -> str:
214
+ return (
215
+ 'Process the Gmail inbox now.\n\n'
216
+ f'search query: {query}\n'
217
+ f'max_results: {max_results}\n'
218
+ f'dry_run: {str(dry_run).lower()}\n'
219
+ )
220
+
221
+
222
+ async def run(
223
+ *,
224
+ settings: settings_mod.Settings,
225
+ reporter: reporter_mod.Reporter,
226
+ query: str | None = None,
227
+ max_results: int | None = None,
228
+ dry_run: bool = False,
229
+ ) -> int:
230
+ """Execute a single agent pass over the inbox.
231
+
232
+ Returns a process exit code — 0 on a successful run with a
233
+ parsed summary, non-zero if the agent produced no summary.
234
+ """
235
+ effective_query = query or settings.search_query
236
+ effective_max = max_results or settings.max_results
237
+ creds = auth.load_credentials(
238
+ credentials_file=config.credentials_path(),
239
+ token_file=config.token_path(),
240
+ scopes=config.GMAIL_SCOPES,
241
+ )
242
+ client = gmail.GmailClient(creds)
243
+ server = tools.build_mcp_server(client)
244
+ options = claude_agent_sdk.ClaudeAgentOptions(
245
+ system_prompt=_render_system_prompt(settings),
246
+ mcp_servers={tools.MCP_SERVER_NAME: server},
247
+ allowed_tools=list(tools.ALLOWED_TOOLS),
248
+ permission_mode='acceptEdits',
249
+ model=settings.model,
250
+ # Isolate from the host's Claude Code setup: no user/project
251
+ # settings, no on-disk settings file, no auto-loaded skills.
252
+ # Only our Gmail MCP server and system prompt drive behavior.
253
+ setting_sources=[],
254
+ settings=None,
255
+ skills=None,
256
+ )
257
+ prompt = _user_prompt(
258
+ query=effective_query,
259
+ max_results=effective_max,
260
+ dry_run=dry_run,
261
+ )
262
+ LOGGER.info(
263
+ 'starting agent run: query=%r max=%d dry_run=%s model=%s',
264
+ effective_query,
265
+ effective_max,
266
+ dry_run,
267
+ settings.model,
268
+ )
269
+ async for message in claude_agent_sdk.query(
270
+ prompt=prompt, options=options
271
+ ):
272
+ _forward(message, reporter)
273
+ return reporter.finish()
274
+
275
+
276
+ def _forward(message: object, reporter: reporter_mod.Reporter) -> None:
277
+ """Feed assistant text blocks to the reporter."""
278
+ if isinstance(message, claude_agent_sdk.AssistantMessage):
279
+ for block in message.content:
280
+ if isinstance(block, claude_agent_sdk.TextBlock):
281
+ reporter.on_text(block.text)
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/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command-line entry point for use-agent."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import pathlib
7
+ import re
8
+ import sys
9
+
10
+ from use_agent import agent, auth, config, reporter
11
+ from use_agent import settings as settings_mod
12
+
13
+ LOGGER = logging.getLogger(__name__)
14
+
15
+ _INTERVAL_RE = re.compile(r'^(?P<n>\d+)(?P<unit>[smhd]?)$')
16
+ _UNIT_SECONDS = {'': 1, 's': 1, 'm': 60, 'h': 3600, 'd': 86400}
17
+ DEFAULT_INTERVAL_SECONDS = 15 * 60
18
+
19
+
20
+ def _parse_interval(raw: str) -> int:
21
+ match = _INTERVAL_RE.fullmatch(raw.strip().lower())
22
+ if not match:
23
+ raise argparse.ArgumentTypeError(
24
+ f'invalid interval {raw!r}; use e.g. 30s, 15m, 2h, 1d'
25
+ )
26
+ seconds = int(match.group('n')) * _UNIT_SECONDS[match.group('unit')]
27
+ if seconds <= 0:
28
+ raise argparse.ArgumentTypeError(
29
+ f'interval must be positive, got {raw!r}'
30
+ )
31
+ return seconds
32
+
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog='use-agent',
37
+ description=('Claude Agent that triages cold sales email in Gmail.'),
38
+ )
39
+ parser.add_argument(
40
+ '-v',
41
+ '--verbose',
42
+ action='store_true',
43
+ help='enable debug logging',
44
+ )
45
+ parser.add_argument(
46
+ '--logfile',
47
+ type=pathlib.Path,
48
+ default=None,
49
+ help='append structured logs to this file (in addition to stderr)',
50
+ )
51
+ sub = parser.add_subparsers(dest='command', required=True)
52
+
53
+ sub.add_parser(
54
+ 'auth',
55
+ help='run the Gmail OAuth flow and save credentials',
56
+ )
57
+
58
+ run_p = sub.add_parser('run', help='process the inbox once')
59
+ run_p.add_argument(
60
+ '--query',
61
+ default=None,
62
+ help=(
63
+ 'override the Gmail search query; defaults to the one '
64
+ 'in config.toml (or one built from the safelist)'
65
+ ),
66
+ )
67
+ run_p.add_argument(
68
+ '--max',
69
+ dest='max_results',
70
+ type=int,
71
+ default=None,
72
+ help='override maximum candidates to examine',
73
+ )
74
+ run_p.add_argument(
75
+ '--dry-run',
76
+ action='store_true',
77
+ help='classify only; do not reply or archive',
78
+ )
79
+ run_p.add_argument(
80
+ '--daemon',
81
+ action='store_true',
82
+ help='run continuously; re-process the inbox every --interval',
83
+ )
84
+ run_p.add_argument(
85
+ '--interval',
86
+ type=_parse_interval,
87
+ default=DEFAULT_INTERVAL_SECONDS,
88
+ help='daemon interval (e.g. 30s, 15m, 2h); default 15m',
89
+ )
90
+ out = run_p.add_mutually_exclusive_group()
91
+ out.add_argument(
92
+ '--plain',
93
+ dest='output',
94
+ action='store_const',
95
+ const=reporter.Mode.PLAIN,
96
+ help='disable ANSI colours and render a pipe-delimited table',
97
+ )
98
+ out.add_argument(
99
+ '--json',
100
+ dest='output',
101
+ action='store_const',
102
+ const=reporter.Mode.JSON,
103
+ help='emit only the parsed summary as JSON on stdout',
104
+ )
105
+ run_p.set_defaults(output=reporter.Mode.PRETTY)
106
+ return parser
107
+
108
+
109
+ def _configure_logging(*, verbose: bool, logfile: pathlib.Path | None) -> None:
110
+ # Log to stderr so --json stdout stays parseable.
111
+ level = logging.DEBUG if verbose else logging.INFO
112
+ fmt = '%(asctime)s %(levelname)s %(name)s: %(message)s'
113
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
114
+ if logfile is not None:
115
+ logfile.parent.mkdir(parents=True, exist_ok=True)
116
+ handlers.append(logging.FileHandler(logfile, encoding='utf-8'))
117
+ logging.basicConfig(
118
+ level=level,
119
+ format=fmt,
120
+ handlers=handlers,
121
+ force=True,
122
+ )
123
+ # SDK's own INFO stream is too chatty for our usage.
124
+ logging.getLogger('claude_agent_sdk').setLevel(logging.WARNING)
125
+
126
+
127
+ def _cmd_auth() -> int:
128
+ creds_file = config.credentials_path()
129
+ token_file = config.token_path()
130
+ auth.load_credentials(
131
+ credentials_file=creds_file,
132
+ token_file=token_file,
133
+ scopes=config.GMAIL_SCOPES,
134
+ )
135
+ print(f'Gmail credentials stored at {token_file}') # noqa: T201
136
+ return 0
137
+
138
+
139
+ async def _run_once(
140
+ settings: settings_mod.Settings,
141
+ args: argparse.Namespace,
142
+ ) -> int:
143
+ rpt = reporter.Reporter(mode=args.output)
144
+ return await agent.run(
145
+ settings=settings,
146
+ reporter=rpt,
147
+ query=args.query,
148
+ max_results=args.max_results,
149
+ dry_run=args.dry_run,
150
+ )
151
+
152
+
153
+ async def _run_daemon(
154
+ settings: settings_mod.Settings,
155
+ args: argparse.Namespace,
156
+ ) -> int:
157
+ interval = args.interval
158
+ LOGGER.info('daemon mode: every %ds (Ctrl-C to stop)', interval)
159
+ while True:
160
+ try:
161
+ rc = await _run_once(settings, args)
162
+ LOGGER.info('iteration finished (rc=%d)', rc)
163
+ except asyncio.CancelledError:
164
+ raise
165
+ except Exception:
166
+ LOGGER.exception('iteration failed, continuing')
167
+ try:
168
+ await asyncio.sleep(interval)
169
+ except asyncio.CancelledError:
170
+ raise
171
+
172
+
173
+ def _cmd_run(args: argparse.Namespace) -> int:
174
+ settings = settings_mod.Settings.load()
175
+ coro = (
176
+ _run_daemon(settings, args)
177
+ if args.daemon
178
+ else _run_once(settings, args)
179
+ )
180
+ try:
181
+ return asyncio.run(coro)
182
+ except KeyboardInterrupt:
183
+ LOGGER.info('interrupted; shutting down')
184
+ return 0
185
+
186
+
187
+ def main(argv: list[str] | None = None) -> int:
188
+ parser = _build_parser()
189
+ args = parser.parse_args(argv)
190
+ _configure_logging(verbose=args.verbose, logfile=args.logfile)
191
+ match args.command:
192
+ case 'auth':
193
+ return _cmd_auth()
194
+ case 'run':
195
+ return _cmd_run(args)
196
+ case _: # pragma: no cover - argparse enforces choices
197
+ parser.error(f'unknown command: {args.command}')
198
+ return 2
199
+
200
+
201
+ if __name__ == '__main__':
202
+ sys.exit(main())
use_agent/config.py ADDED
@@ -0,0 +1,44 @@
1
+ """Paths and static defaults for use-agent."""
2
+
3
+ import os
4
+ import pathlib
5
+
6
+ # Single scope. gmail.modify covers read, label changes, and send.
7
+ GMAIL_SCOPES: tuple[str, ...] = (
8
+ 'https://www.googleapis.com/auth/gmail.modify',
9
+ )
10
+
11
+ PROMPTS_DIR: pathlib.Path = pathlib.Path(__file__).parent / 'prompts'
12
+ CLASSIFIER_PROMPT: pathlib.Path = PROMPTS_DIR / 'classifier.md'
13
+ REPLY_PROMPT: pathlib.Path = PROMPTS_DIR / 'reply.md'
14
+
15
+
16
+ def _config_home() -> pathlib.Path:
17
+ raw = os.environ.get('XDG_CONFIG_HOME')
18
+ if raw:
19
+ return pathlib.Path(raw) / 'use-agent'
20
+ return pathlib.Path.home() / '.config' / 'use-agent'
21
+
22
+
23
+ def credentials_path() -> pathlib.Path:
24
+ """Path to the Google OAuth client secret JSON."""
25
+ override = os.environ.get('USE_AGENT_CREDENTIALS')
26
+ if override:
27
+ return pathlib.Path(override)
28
+ return _config_home() / 'credentials.json'
29
+
30
+
31
+ def token_path() -> pathlib.Path:
32
+ """Path to the stored OAuth refresh token."""
33
+ override = os.environ.get('USE_AGENT_TOKEN')
34
+ if override:
35
+ return pathlib.Path(override)
36
+ return _config_home() / 'token.json'
37
+
38
+
39
+ def config_file_path() -> pathlib.Path:
40
+ """Path to the TOML configuration file."""
41
+ override = os.environ.get('USE_AGENT_CONFIG')
42
+ if override:
43
+ return pathlib.Path(override)
44
+ return _config_home() / 'config.toml'