hunch-sdk 0.3.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.
hunch/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Hunch — drive your Mac focus-free over MCP.
2
+
3
+ This module is intentionally dependency-free: the CLI (config, creds, connect,
4
+ doctor) must work even when pyobjc or the MCP SDK are missing, so nothing here
5
+ may import them. The server loads lazily via `hunch serve`.
6
+ """
7
+
8
+ __version__ = "0.3.0"
9
+
10
+ # Public SDK surface, loaded lazily (PEP 562) so bare `import hunch` stays dependency-free:
11
+ # touching any of these names pulls in pyobjc via the real modules.
12
+ _LAZY = {
13
+ "Hunch": ".sdk",
14
+ "Agent": ".agent",
15
+ "AgentResult": ".agent",
16
+ "HunchError": ".gate",
17
+ "ApprovalDenied": ".gate",
18
+ "AccessibilityNotGranted": ".gate",
19
+ "WebNotOpen": ".gate",
20
+ "StaleRef": ".local_mac",
21
+ }
22
+
23
+
24
+ def __getattr__(name):
25
+ if name in _LAZY:
26
+ from importlib import import_module
27
+ return getattr(import_module(_LAZY[name], __name__), name)
28
+ raise AttributeError(f"module 'hunch' has no attribute {name!r}")
29
+
30
+
31
+ def __dir__():
32
+ return sorted([*globals(), *_LAZY])
hunch/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
hunch/agent.py ADDED
@@ -0,0 +1,445 @@
1
+ """agent.py — the Hunch agent loop: an LLM drives this Mac through the Hunch
2
+ primitives, on the user's own machine and logged-in apps.
3
+
4
+ from hunch import Hunch
5
+ mac = Hunch()
6
+ result = mac.agent.run("reply to Sarah's latest email, but don't send it")
7
+ print(result.text)
8
+
9
+ Anthropic-only in v1: Claude reads the Hunch playbook as its system prompt and drives
10
+ the Mac through the same tools the MCP server exposes. Other models can use the instance
11
+ SDK primitives directly in their own harness. `anthropic` is an OPTIONAL dependency
12
+ (pip install 'hunch-sdk[agent]'), imported lazily so plain `import hunch` stays clean.
13
+
14
+ Provider seam: request construction and response parsing are isolated in _request /
15
+ _parse_response so a future backend adapter has a clean boundary — no abstraction layer
16
+ is built now.
17
+ """
18
+ import base64
19
+ from dataclasses import dataclass, field
20
+
21
+ from .playbook import HUNCH_PLAYBOOK
22
+ from .gate import HunchError, ApprovalDenied, AccessibilityNotGranted, WebNotOpen
23
+
24
+ __all__ = ["Agent", "AgentResult"]
25
+
26
+ DEFAULT_MODEL = "claude-opus-4-8"
27
+
28
+ # ── tool schemas ──────────────────────────────────────────────────────────────
29
+ # The full-input action item (native AX layer). Shared by the `act` tool.
30
+ _ACTION_ITEM = {
31
+ "type": "object",
32
+ "properties": {
33
+ "action": {"type": "string",
34
+ "enum": ["click", "right_click", "select", "type", "menu", "key", "click_xy"]},
35
+ "ref": {"type": "string"}, "text": {"type": "string"},
36
+ "path": {"type": "array", "items": {"type": "string"}},
37
+ "key": {"type": "string"}, "modifiers": {"type": "array", "items": {"type": "string"}},
38
+ "x": {"type": "integer"}, "y": {"type": "integer"}},
39
+ "required": ["action"]}
40
+
41
+ # The web/CDP action item — a smaller verb set (no menu/pixel; navigate instead).
42
+ _WEB_ACTION_ITEM = {
43
+ "type": "object",
44
+ "properties": {
45
+ "action": {"type": "string", "enum": ["click", "type", "key", "navigate"]},
46
+ "ref": {"type": "string"}, "text": {"type": "string"},
47
+ "key": {"type": "string"}, "modifiers": {"type": "array", "items": {"type": "string"}},
48
+ "url": {"type": "string"}},
49
+ "required": ["action"]}
50
+
51
+
52
+ def _obj(props=None, required=None):
53
+ return {"type": "object", "properties": props or {}, **({"required": required} if required else {})}
54
+
55
+
56
+ # Names identical to the MCP tools so every HUNCH_PLAYBOOK reference resolves. Descriptions
57
+ # are condensed (the deep procedural guidance lives in the playbook system prompt); each keeps
58
+ # the prescriptive when-to-call sentence, which measurably lifts should-call rate on Opus.
59
+ AGENT_TOOLS = [
60
+ {"name": "snapshot",
61
+ "description": ("See an app as an accessibility tree ([ref] per element) — your primary way "
62
+ "to read UI focus-free. Pass an app name or leave blank for the frontmost. "
63
+ "ref='e42' re-walks just that element's subtree; truncated trees end in `…` "
64
+ "markers naming what was dropped."),
65
+ "input_schema": _obj({"app": {"type": "string"}, "ref": {"type": "string"},
66
+ "max_depth": {"type": "integer"}, "max_nodes": {"type": "integer"}})},
67
+ {"name": "find",
68
+ "description": ("Search an app's WHOLE tree for matching elements without reading a full "
69
+ "snapshot — the cheap way to locate one control in a big window. Filter by "
70
+ "role (e.g. 'button', case-insensitive) and/or name_contains (substring of "
71
+ "title/description/value). Returns actable [ref]s."),
72
+ "input_schema": _obj({"role": {"type": "string"}, "name_contains": {"type": "string"},
73
+ "app": {"type": "string"}, "max_results": {"type": "integer"}})},
74
+ {"name": "act",
75
+ "description": ("Run UI actions in order by ref, then get the updated tree. Verbs: click "
76
+ "(activate), right_click (context menu), select (highlight a row), type (set "
77
+ "a field by ref = focus-free; no ref types at focus = STEALS FOCUS), menu "
78
+ "(invoke a menu-bar path, e.g. ['File','Move to Trash'] — the focus-free "
79
+ "stand-in for ⌘-shortcuts), key/click_xy (STEAL FOCUS, last resort). Prefer "
80
+ "the focus-free verbs. Pass `reason` when any action steals focus."),
81
+ "input_schema": _obj({"actions": {"type": "array", "items": _ACTION_ITEM},
82
+ "reason": {"type": "string"}}, ["actions"])},
83
+ {"name": "screenshot",
84
+ "description": ("See the frontmost app as a PNG — only for genuinely visual content the tree "
85
+ "can't convey (an image, a chart). To read UI text or check an action, "
86
+ "re-snapshot instead."),
87
+ "input_schema": _obj()},
88
+ {"name": "list_apps", "description": "List the running GUI apps you can target with snapshot.",
89
+ "input_schema": _obj()},
90
+ {"name": "launch_app",
91
+ "description": ("Launch or focus an app (reliable OS call — beats clicking the Dock). Set "
92
+ "force_accessibility=true for an Electron/Chromium app whose tree reads empty. "
93
+ "In simultaneous mode it launches in the background."),
94
+ "input_schema": _obj({"name": {"type": "string"},
95
+ "force_accessibility": {"type": "boolean"},
96
+ "reason": {"type": "string"}}, ["name"])},
97
+ {"name": "simultaneous_mode",
98
+ "description": ("Toggle simultaneous mode. ON = never steal the user's cursor/keyboard/view "
99
+ "(background reads, focus-free actions only, shared-input actions refused). "
100
+ "OFF = may bring apps forward and use the full input set."),
101
+ "input_schema": _obj({"on": {"type": "boolean"}})},
102
+ {"name": "quit_app", "description": "Quit an app via the OS (reliable regardless of focus).",
103
+ "input_schema": _obj({"name": {"type": "string"}}, ["name"])},
104
+ {"name": "focus_app",
105
+ "description": ("Bring an app to the front and target it. This switches the user's view — "
106
+ "always pass `reason` (one short sentence for why)."),
107
+ "input_schema": _obj({"name": {"type": "string"}, "reason": {"type": "string"}}, ["name"])},
108
+ {"name": "web_open",
109
+ "description": ("Open a Chromium browser or Electron app for FOCUS-FREE control over CDP — "
110
+ "the way to drive web/Electron apps in the background. `app` is the browser "
111
+ "(default 'Google Chrome'); put a website in `url`. Uses the persistent Hunch "
112
+ "profile; call web_login once if it isn't signed in."),
113
+ "input_schema": _obj({"app": {"type": "string"}, "url": {"type": "string"},
114
+ "isolated": {"type": "boolean"}})},
115
+ {"name": "web_login",
116
+ "description": ("Open a background, banner-tagged window for the HUMAN to sign in once (Hunch "
117
+ "never sees the password); the login then persists. Fires a desktop notification."),
118
+ "input_schema": _obj({"app": {"type": "string"}, "url": {"type": "string"}})},
119
+ {"name": "web_snapshot",
120
+ "description": "Read the CDP-controlled page as an accessibility tree. Call web_open first.",
121
+ "input_schema": _obj()},
122
+ {"name": "web_screenshot",
123
+ "description": ("PNG of the CDP page itself (focus-free) — for visual web content the tree "
124
+ "can't convey. Use this, never the OS screenshot, for the background browser."),
125
+ "input_schema": _obj()},
126
+ {"name": "web_act",
127
+ "description": ("Run focus-free page actions, then get the updated tree. Verbs: click (by ref), "
128
+ "type (REPLACES a field; picks <select> options by visible text), key, navigate "
129
+ "(only to a URL you were given or read from the page — click links, don't guess)."),
130
+ "input_schema": _obj({"actions": {"type": "array", "items": _WEB_ACTION_ITEM}}, ["actions"])},
131
+ {"name": "web_restart",
132
+ "description": ("Recover a BROKEN CDP browser by quitting and reopening it fresh (login kept). "
133
+ "Last resort — don't restart a merely-slow page; wait and re-snapshot first."),
134
+ "input_schema": _obj({"app": {"type": "string"}, "url": {"type": "string"}})},
135
+ {"name": "web_tabs",
136
+ "description": "List the open CDP tabs (index, title, URL, which is current).",
137
+ "input_schema": _obj()},
138
+ {"name": "web_switch_tab",
139
+ "description": "Switch the CDP session to a tab by index (see web_tabs), then web_snapshot.",
140
+ "input_schema": _obj({"index": {"type": "integer"}}, ["index"])},
141
+ {"name": "list_credentials",
142
+ "description": ("List the service NAMES the user saved credentials for (names + kind only, "
143
+ "never values). Fill them with web_fill_login / web_fill_secret."),
144
+ "input_schema": _obj()},
145
+ {"name": "web_fill_login",
146
+ "description": ("Fill the current CDP page's login form from the user's saved credential for "
147
+ "`service`, WITHOUT the values entering your context — you only learn which "
148
+ "fields were filled. Then submit via web_act. web_open first."),
149
+ "input_schema": _obj({"service": {"type": "string"}}, ["service"])},
150
+ {"name": "web_fill_secret",
151
+ "description": ("Type the user's saved protected value (API key/token) for `service` into a "
152
+ "field on the current CDP page, WITHOUT the value entering your context. "
153
+ "web_snapshot first and pass the field's ref."),
154
+ "input_schema": _obj({"service": {"type": "string"}, "ref": {"type": "string"}}, ["service"])},
155
+ {"name": "notify_user",
156
+ "description": ("Alert the user when you need them SHORTLY — to finish a login, approve a 2FA "
157
+ "prompt, solve a captcha, or make a decision only they can. Call this the moment "
158
+ "you hit a step only the human can do."),
159
+ "input_schema": _obj({"message": {"type": "string"}}, ["message"])},
160
+ {"name": "request_focus",
161
+ "description": ("Ask the user's permission BEFORE a focus-stealing step you'll do via other "
162
+ "tools. Pops a one-click Go ahead / Cancel dialog and returns their choice."),
163
+ "input_schema": _obj({"reason": {"type": "string"}}, ["reason"])},
164
+ {"name": "trash",
165
+ "description": ("Move file(s)/folder(s) to the Trash by path — focus-free and reversible. Use "
166
+ "this to delete files instead of driving Finder."),
167
+ "input_schema": _obj({"paths": {"type": "array", "items": {"type": "string"}}}, ["paths"])},
168
+ {"name": "file_op",
169
+ "description": ("Focus-free filesystem op by path: op='move'|'copy' (src -> dst) or op='mkdir' "
170
+ "(create a folder at src). To delete, use trash."),
171
+ "input_schema": _obj({"op": {"type": "string", "enum": ["move", "copy", "mkdir"]},
172
+ "src": {"type": "string"}, "dst": {"type": "string"}}, ["op", "src"])},
173
+ {"name": "open_file",
174
+ "description": ("Open a file/folder/URL/app-deep-link with its default (or a named) app — "
175
+ "focus-free launch (also 'mailto:', 'spotify:track:...')."),
176
+ "input_schema": _obj({"path": {"type": "string"}, "app": {"type": "string"}}, ["path"])},
177
+ {"name": "reveal_in_finder",
178
+ "description": "Reveal/select item(s) in a Finder window by path (this does front Finder).",
179
+ "input_schema": _obj({"paths": {"type": "array", "items": {"type": "string"}}}, ["paths"])},
180
+ {"name": "clipboard_get", "description": "Read the clipboard's text — focus-free (no ⌘C).",
181
+ "input_schema": _obj()},
182
+ {"name": "clipboard_set", "description": "Put text on the clipboard — focus-free (no ⌘V).",
183
+ "input_schema": _obj({"text": {"type": "string"}}, ["text"])},
184
+ {"name": "applescript",
185
+ "description": ("Run AppleScript to control scriptable native apps FOCUS-FREE via Apple Events "
186
+ "— Mail, Messages, Notes, Reminders, Calendar, Music, Finder, Safari. Prefer "
187
+ "this over UI-driving those apps. Risky scripts prompt the user."),
188
+ "input_schema": _obj({"script": {"type": "string"}}, ["script"])},
189
+ ]
190
+
191
+ AGENT_ADDENDUM = """\
192
+
193
+ ── You are an autonomous agent driving THIS Mac ──
194
+ You are driving the user's real Mac on their behalf through the tools above. The playbook
195
+ above is your operating manual — follow its NATIVE-FIRST routing and layer order.
196
+
197
+ TERMINATION: when the task is complete — or you are blocked on something only the human can
198
+ do (a login, a 2FA prompt, a decision) AFTER calling notify_user — end your turn with a
199
+ plain final message summarizing the outcome. There is no "done" tool; your final message is
200
+ the deliverable. Do not end mid-task with only a statement of intent ("I'll now…") — do the
201
+ action, then report.
202
+
203
+ REFUSALS: a tool result starting with "REFUSED" or "User did NOT approve" is a hard human
204
+ decision, not a transient error. Do NOT retry the identical call — adapt (a focus-free
205
+ alternative) or call notify_user and stop.
206
+
207
+ CAUTION: before an irreversible or outward action (sending a message/email, deleting beyond
208
+ the Trash, a purchase), confirm intent with the user via notify_user unless they already
209
+ asked for exactly that. Report outcomes faithfully — if something failed, say so."""
210
+
211
+
212
+ # ── exception → tool_result content mapping ─────────────────────────────────────
213
+ def _img(png_bytes):
214
+ return [{"type": "image", "source": {"type": "base64", "media_type": "image/png",
215
+ "data": base64.b64encode(png_bytes).decode()}}]
216
+
217
+
218
+ # Dispatcher: tool name -> callable(mac, args) -> content (str or image-block list).
219
+ _DISPATCH = {
220
+ "snapshot": lambda m, a: m.snapshot(a.get("app", ""), ref=a.get("ref") or None,
221
+ max_depth=a.get("max_depth"), max_nodes=a.get("max_nodes")),
222
+ "find": lambda m, a: m.find(role=a.get("role") or None, name_contains=a.get("name_contains") or None,
223
+ app=a.get("app", ""), max_results=a.get("max_results", 20)),
224
+ "act": lambda m, a: m.act(a.get("actions", []), reason=a.get("reason", "")),
225
+ "screenshot": lambda m, a: _img(m.screenshot()),
226
+ "list_apps": lambda m, a: m.list_apps(),
227
+ "launch_app": lambda m, a: m.launch_app(a["name"], force_accessibility=a.get("force_accessibility", False),
228
+ reason=a.get("reason", "")),
229
+ "simultaneous_mode": lambda m, a: _set_simultaneous(m, a.get("on", True)),
230
+ "quit_app": lambda m, a: m.quit_app(a["name"]),
231
+ "focus_app": lambda m, a: m.focus_app(a["name"], reason=a.get("reason", "")),
232
+ "web_open": lambda m, a: m.web.open(url=a.get("url", ""), app=a.get("app", "Google Chrome"),
233
+ isolated=a.get("isolated", False)),
234
+ "web_login": lambda m, a: m.web.login(url=a.get("url", ""), app=a.get("app", "Google Chrome")),
235
+ "web_snapshot": lambda m, a: m.web.snapshot(),
236
+ "web_screenshot": lambda m, a: _img(m.web.screenshot()),
237
+ "web_act": lambda m, a: m.web.act(a.get("actions", [])),
238
+ "web_restart": lambda m, a: m.web.restart(url=a.get("url", ""), app=a.get("app", "Google Chrome")),
239
+ "web_tabs": lambda m, a: m.web.tabs(),
240
+ "web_switch_tab": lambda m, a: m.web.switch_tab(a["index"]),
241
+ "list_credentials": lambda m, a: m.list_credentials(),
242
+ "web_fill_login": lambda m, a: m.web.fill_login(a["service"]),
243
+ "web_fill_secret": lambda m, a: m.web.fill_secret(a["service"], ref=a.get("ref", "")),
244
+ "notify_user": lambda m, a: _notify(m, a["message"]),
245
+ "request_focus": lambda m, a: _request_focus(m, a["reason"]),
246
+ "trash": lambda m, a: m.files.trash(a.get("paths", [])),
247
+ "file_op": lambda m, a: _file_op(m, a),
248
+ "open_file": lambda m, a: m.files.open(a["path"], app=a.get("app") or None),
249
+ "reveal_in_finder": lambda m, a: m.files.reveal(a.get("paths", [])),
250
+ "clipboard_get": lambda m, a: m.clipboard.get(),
251
+ "clipboard_set": lambda m, a: m.clipboard.set(a["text"]),
252
+ "applescript": lambda m, a: m.applescript(a["script"]),
253
+ }
254
+
255
+
256
+ def _set_simultaneous(mac, on):
257
+ mac.simultaneous = bool(on)
258
+ return ("simultaneous mode ON — Hunch will not touch your foreground, cursor, or keyboard"
259
+ if on else "simultaneous mode OFF — Hunch may bring apps forward and use the cursor/keyboard")
260
+
261
+
262
+ def _notify(mac, message):
263
+ mac.notify(message, "Hunch needs you")
264
+ return f"notified the user: {message}"
265
+
266
+
267
+ def _request_focus(mac, reason):
268
+ ok = mac._gate.confirm_dialog(f"Hunch wants to {reason}. Allow it to take over your screen?")
269
+ return ("user clicked Go ahead — proceed, then restore their previous app afterwards"
270
+ if ok else "user did not approve — do NOT proceed with the focus-stealing action")
271
+
272
+
273
+ def _file_op(mac, a):
274
+ op, src, dst = a.get("op"), a.get("src", ""), a.get("dst", "")
275
+ if op == "move":
276
+ return mac.files.move(src, dst)
277
+ if op == "copy":
278
+ return mac.files.copy(src, dst)
279
+ if op == "mkdir":
280
+ return mac.files.mkdir(src)
281
+ return f"unknown op {op!r} — use 'move', 'copy', or 'mkdir' (or the trash tool to delete)"
282
+
283
+
284
+ def _run_tool(mac, tool_use):
285
+ """Dispatch one tool_use block -> a tool_result dict. Expected Hunch exceptions become
286
+ plain content strings so the model can adapt; only genuinely unexpected exceptions set
287
+ is_error (mirrors the MCP server's return-the-message behavior)."""
288
+ from .local_mac import StaleRef
289
+ name = tool_use.name
290
+ tid = tool_use.id
291
+ args = tool_use.input or {}
292
+ fn = _DISPATCH.get(name)
293
+ if fn is None:
294
+ return {"type": "tool_result", "tool_use_id": tid, "content": f"unknown tool {name}",
295
+ "is_error": True}
296
+ try:
297
+ content = fn(mac, args)
298
+ except ApprovalDenied as e:
299
+ content = (f"REFUSED: {e} — the user declined; do not retry the identical action, "
300
+ "adapt or call notify_user")
301
+ except StaleRef:
302
+ content = "ref is stale — re-snapshot the app and use fresh refs"
303
+ except (WebNotOpen, AccessibilityNotGranted, HunchError) as e:
304
+ content = str(e)
305
+ except Exception as e: # noqa: BLE001 — genuinely unexpected: flag it
306
+ return {"type": "tool_result", "tool_use_id": tid, "content": f"error: {e}", "is_error": True}
307
+ return {"type": "tool_result", "tool_use_id": tid, "content": content}
308
+
309
+
310
+ def _preview(content):
311
+ if isinstance(content, list):
312
+ return "[image]"
313
+ s = str(content)
314
+ return s if len(s) <= 200 else s[:200] + "…"
315
+
316
+
317
+ # ── caching: rolling breakpoint on the last user-message block ──────────────────
318
+ def _mark_cache(messages):
319
+ """Put a cache_control breakpoint on the last block of the most recent user message and
320
+ clear it from earlier ones. Only touches dict blocks (our tool_result / text dicts) — it
321
+ NEVER mutates the pydantic assistant/thinking blocks we replay verbatim."""
322
+ last = None
323
+ for msg in messages:
324
+ content = msg.get("content")
325
+ if not isinstance(content, list):
326
+ continue
327
+ for block in content:
328
+ if isinstance(block, dict):
329
+ block.pop("cache_control", None)
330
+ if msg.get("role") == "user":
331
+ last = block
332
+ if last is not None:
333
+ last["cache_control"] = {"type": "ephemeral"}
334
+
335
+
336
+ @dataclass
337
+ class AgentResult:
338
+ text: str
339
+ turns: int
340
+ stop_reason: str
341
+ usage: dict = field(default_factory=dict)
342
+ aborted: bool = False
343
+
344
+
345
+ class Agent:
346
+ """The agent loop. Created lazily as `Hunch.agent`. Holds conversation state across
347
+ act() calls (continuation); call reset() to start a fresh task."""
348
+
349
+ def __init__(self, hunch, client=None):
350
+ self._h = hunch
351
+ self._client = client # test seam; None -> lazily anthropic.Anthropic()
352
+ self.messages = []
353
+
354
+ def reset(self):
355
+ """Clear the conversation for an unrelated task (same Hunch instance and gates)."""
356
+ self.messages = []
357
+
358
+ def _ensure_client(self):
359
+ if self._client is None:
360
+ try:
361
+ import anthropic
362
+ except ImportError as e:
363
+ raise HunchError("the agent loop needs the optional 'anthropic' package — "
364
+ "install it with: pip install 'hunch-sdk[agent]'") from e
365
+ self._client = anthropic.Anthropic() # env key / auth token / ant profile
366
+ return self._client
367
+
368
+ def _system(self, system_suffix):
369
+ blocks = [{"type": "text", "text": HUNCH_PLAYBOOK + "\n" + AGENT_ADDENDUM,
370
+ "cache_control": {"type": "ephemeral"}}]
371
+ if system_suffix:
372
+ blocks.append({"type": "text", "text": system_suffix})
373
+ return blocks
374
+
375
+ def _request(self, client, model, max_tokens, system, effort):
376
+ """Provider seam: build kwargs + run one streamed turn, return the final Message."""
377
+ kwargs = dict(model=model, max_tokens=max_tokens, system=system,
378
+ tools=AGENT_TOOLS, messages=self.messages,
379
+ thinking={"type": "adaptive"})
380
+ if effort:
381
+ kwargs["output_config"] = {"effort": effort}
382
+ with client.messages.stream(**kwargs) as stream:
383
+ return stream.get_final_message()
384
+
385
+ @staticmethod
386
+ def _accumulate(usage, resp_usage):
387
+ for k in ("input_tokens", "output_tokens",
388
+ "cache_creation_input_tokens", "cache_read_input_tokens"):
389
+ usage[k] = usage.get(k, 0) + (getattr(resp_usage, k, 0) or 0)
390
+
391
+ def run(self, task, model=DEFAULT_MODEL, max_turns=40, on_event=None,
392
+ system_suffix="", effort=None, max_tokens=16000):
393
+ """Run the agent loop until Claude finishes the task (end_turn), is blocked, or hits
394
+ max_turns. Returns an AgentResult. on_event(kind, data) receives 'text' | 'tool' |
395
+ 'tool_result' | 'done' | 'error' as work streams."""
396
+ client = self._ensure_client() # friendly HunchError if 'anthropic' isn't installed
397
+ try:
398
+ import anthropic
399
+ _AuthError = anthropic.AuthenticationError
400
+ except Exception: # injected fake client without anthropic present
401
+ _AuthError = ()
402
+ emit = on_event or (lambda kind, data: None)
403
+ self.messages.append({"role": "user", "content": task})
404
+ usage = {}
405
+ stop_reason, final_text, aborted, turn = "max_turns", "", True, 0
406
+
407
+ for turn in range(1, max_turns + 1):
408
+ _mark_cache(self.messages)
409
+ try:
410
+ resp = self._request(client, model, max_tokens, self._system(system_suffix), effort)
411
+ except _AuthError as e:
412
+ raise HunchError("no valid Anthropic credentials — set ANTHROPIC_API_KEY or "
413
+ "run `ant auth login` (then retry)") from e
414
+ self._accumulate(usage, resp.usage)
415
+ for b in resp.content:
416
+ if getattr(b, "type", None) == "text" and b.text.strip():
417
+ emit("text", b.text.strip())
418
+ self.messages.append({"role": "assistant", "content": resp.content}) # verbatim replay
419
+
420
+ if resp.stop_reason == "tool_use":
421
+ results = []
422
+ for tu in [b for b in resp.content if getattr(b, "type", None) == "tool_use"]:
423
+ emit("tool", {"name": tu.name, "input": tu.input})
424
+ r = _run_tool(self._h, tu)
425
+ emit("tool_result", _preview(r["content"]))
426
+ results.append(r)
427
+ self.messages.append({"role": "user", "content": results}) # ALL results, ONE message
428
+ continue
429
+ if resp.stop_reason == "pause_turn": # defensive; no server tools in v1
430
+ continue
431
+
432
+ stop_reason = resp.stop_reason
433
+ if stop_reason == "end_turn":
434
+ final_text = "\n".join(b.text for b in resp.content
435
+ if getattr(b, "type", None) == "text").strip()
436
+ aborted = False
437
+ emit("done", final_text)
438
+ else: # max_tokens | refusal | other
439
+ emit("error", f"stopped: {stop_reason}")
440
+ break
441
+ else:
442
+ emit("error", f"aborted after max_turns={max_turns}")
443
+
444
+ return AgentResult(text=final_text, turns=turn, stop_reason=stop_reason,
445
+ usage=usage, aborted=aborted)
hunch/assets/hunch.png ADDED
Binary file