@allansantos-dev/smart-tool 0.9.43 → 0.9.45

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.
@@ -5,8 +5,9 @@ reaproveitamento, e `research_max_iterations` limita o retorno por rodada. Write
5
5
  `router-metrics.jsonl` — nada no daemon lê isto de volta; quem lê é
6
6
  `.token-guard/analyze_research.py`.
7
7
 
8
- Grava só o que a calibração usa: textos de consulta/tópico truncados e números. Nunca
9
- snippets nem páginas, que são conteúdo de terceiros e não ajudam a decidir o valor.
8
+ Grava só o que a calibração usa: textos de consulta/tópico truncados e números, mais o
9
+ `requester` (sessão e pasta de quem pediu, ver requester.py). Nunca snippets nem páginas,
10
+ que são conteúdo de terceiros e não ajudam a decidir o valor.
10
11
  """
11
12
  import json
12
13
  import os
@@ -15,6 +16,7 @@ import threading
15
16
  import time
16
17
 
17
18
  import atomic_io
19
+ import requester
18
20
 
19
21
  METRICS_PATH = os.path.join(paths.DATA_DIR, "research-metrics.jsonl")
20
22
  METRICS_MAX_BYTES = 2 * 1024 * 1024
@@ -34,7 +36,7 @@ def _short(text):
34
36
 
35
37
 
36
38
  def _append(record):
37
- record = {"ts": time.time(), **record}
39
+ record = {"ts": time.time(), **({"requester": requester.current()} if requester.current() else {}), **record}
38
40
  try:
39
41
  line = json.dumps(record, ensure_ascii=False) + "\n"
40
42
  with _LOCK:
@@ -50,6 +50,7 @@ import indexer
50
50
  import index_views
51
51
  import index_inventory
52
52
  import usage_meter
53
+ import document_read
53
54
  import document_text
54
55
  import dossier
55
56
  import scout
@@ -60,6 +61,7 @@ import project_identity
60
61
  import project_store
61
62
  import project_scheduler
62
63
  import project_monitor
64
+ import requester
63
65
  import research_metrics
64
66
  import research_cache
65
67
  import router
@@ -350,7 +352,9 @@ SERVER_INSTRUCTIONS = (
350
352
  "project_manage action=affected_tests lists the tests to run. When smart_search returns nothing, retry smart_search with "
351
353
  "other terms; do not switch to Grep, Read of folders or shell search. To answer a question that would mean reading "
352
354
  "several files, call smart_scout: it reads them inside Smart Tool and returns only the lines that answer, as "
353
- "path:lines with the literal text."
355
+ "path:lines with the literal text. To read a PDF, Word, PowerPoint or Excel file, call read_document (load it with "
356
+ "ToolSearch if it is not in your tool list): it returns the page count, size and estimated tokens first, then the "
357
+ "text of only the pages, slides or sheets you ask for, in far fewer tokens than the whole file."
354
358
  )
355
359
 
356
360
  TOOLS = [
@@ -540,6 +544,31 @@ TOOLS = [
540
544
  "required": ["url", "prompt"],
541
545
  },
542
546
  },
547
+ {
548
+ "name": "read_document",
549
+ "description": (
550
+ "Reads a PDF, Word (.docx), PowerPoint (.pptx) or Excel (.xlsx) file as compact text, a range at a time, "
551
+ "inside Smart Tool so the file never enters your context whole. The answer opens with the facts: type, "
552
+ "size, page, slide, sheet or part count and the estimated tokens of all the text; then comes the text of "
553
+ "the range you asked for (default: from the start, up to max_chars) with a `next:` line naming the range "
554
+ "to continue. PDF pages come with the layout noise removed (ligatures, hyphenation, wrapped lines); docx "
555
+ "as markdown (headings, lists, tables) in parts of about 6,000 characters because a docx has no fixed "
556
+ "pages; pptx per slide with the speaker notes; xlsx per sheet, one tab-separated line per row with the "
557
+ "cached formula values. Use info_only first to size a large file and pick the pages you need. A scanned "
558
+ "PDF (no text layer) fails with that reason; there is no OCR. Not for plain text or code: use Read."
559
+ ),
560
+ "inputSchema": {
561
+ "type": "object",
562
+ "properties": {
563
+ "path": {"type": "string", "description": "Absolute path of the .pdf, .docx, .pptx or .xlsx file"},
564
+ "range": {"type": "string", "description": "Pages, slides, docx parts or sheets to read: `3`, `3-5`, `7-` (to the end), `-4` (the first four) or a comma list like `1,4-6`; a sheet name also works for a workbook. Default: from the first one"},
565
+ "max_chars": {"type": "integer", "minimum": 1000, "maximum": document_read.MAX_CHARS_LIMIT, "default": document_read.DEFAULT_MAX_CHARS, "description": "Cap of the text returned (about four characters per token)"},
566
+ "info_only": {"type": "boolean", "default": False, "description": "Return only the facts (type, size, counts, estimated tokens), no text"},
567
+ },
568
+ "required": ["path"],
569
+ "additionalProperties": False,
570
+ },
571
+ },
543
572
  {
544
573
  "name": "web_search_result",
545
574
  "_meta": ALWAYS_LOAD,
@@ -2615,14 +2644,15 @@ def _fan_out_web_search(query, num, budget_s=None, site_domains=()):
2615
2644
  for tier_name in web_search_adapters.FAN_OUT_TIERS:
2616
2645
  fut = WEB_SEARCH_EXECUTOR.submit(_run_tier, tier_name, query, num)
2617
2646
  futures[fut] = tier_name
2647
+ who = requester.current() # the callbacks run in the pool's threads, outside this request's context
2618
2648
 
2619
2649
  def _record_on_done(tier_name):
2620
2650
  def _cb(fut):
2621
2651
  try:
2622
2652
  fut.result()
2623
- _record_tier_health(tier_name, success=True)
2653
+ _record_tier_health(tier_name, success=True, who=who)
2624
2654
  except Exception as exc:
2625
- _record_tier_failure(tier_name, exc)
2655
+ _record_tier_failure(tier_name, exc, who)
2626
2656
  return _cb
2627
2657
 
2628
2658
  for fut, tier_name in futures.items():
@@ -2690,7 +2720,7 @@ def _handle_web_search_body(arguments):
2690
2720
  job_id = uuid.uuid4().hex
2691
2721
  _set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
2692
2722
  try:
2693
- DEEP_JOB_EXECUTOR.submit(_run_deep_job, job_id, query, scope)
2723
+ DEEP_JOB_EXECUTOR.submit(contextvars.copy_context().run, _run_deep_job, job_id, query, scope)
2694
2724
  except Exception as exc:
2695
2725
  # Se o submit() em si falhar (pool fechado/saturado), o job já foi
2696
2726
  # registrado como "pending" — sem isto, ficaria assim pra sempre,
@@ -2702,7 +2732,7 @@ def _handle_web_search_body(arguments):
2702
2732
  job_id = uuid.uuid4().hex
2703
2733
  _set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
2704
2734
  try:
2705
- RESEARCH_JOB_EXECUTOR.submit(_run_research_job, job_id, query, num, scope)
2735
+ RESEARCH_JOB_EXECUTOR.submit(contextvars.copy_context().run, _run_research_job, job_id, query, num, scope)
2706
2736
  except Exception as exc:
2707
2737
  _set_job(job_id, status="error", result=None, error=_sanitize_text(f"{type(exc).__name__}: {exc}"), created_at=time.time())
2708
2738
  return _to_json_sanitized({"job_id": job_id, "status": "pending"})
@@ -3210,14 +3240,14 @@ def _annotate_quick_results(rows, classification, cache=None):
3210
3240
  return result
3211
3241
 
3212
3242
 
3213
- def _record_tier_health(tier_name, success, outcome=None):
3243
+ def _record_tier_health(tier_name, success, outcome=None, who=None):
3214
3244
  try:
3215
- web_search_health.record(tier_name, success, outcome)
3245
+ web_search_health.record(tier_name, success, outcome, who or requester.current())
3216
3246
  except Exception:
3217
3247
  pass # telemetria é best-effort — nunca pode derrubar uma busca já resolvida
3218
3248
 
3219
3249
 
3220
- def _record_tier_failure(tier_name, exc):
3250
+ def _record_tier_failure(tier_name, exc, who=None):
3221
3251
  """Health entry for a failed tier call, keeping a 429, a captcha and a quota pause apart from other errors."""
3222
3252
  if isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
3223
3253
  outcome = "paused"
@@ -3227,7 +3257,7 @@ def _record_tier_failure(tier_name, exc):
3227
3257
  outcome = "captcha"
3228
3258
  else:
3229
3259
  outcome = "failed"
3230
- _record_tier_health(tier_name, False, outcome)
3260
+ _record_tier_health(tier_name, False, outcome, who)
3231
3261
 
3232
3262
 
3233
3263
  _is_safe_crawl_target = web_fetch.is_safe_target
@@ -3979,7 +4009,29 @@ def _session_audit_hook(handler):
3979
4009
  return output
3980
4010
 
3981
4011
 
4012
+ def _handle_read_document(arguments):
4013
+ """read_document: facts and a page, slide, sheet or part range of a PDF, docx, pptx or xlsx as compact text (see
4014
+ document_read). Fails loudly, with the reason, for a path that is not absolute or readable, an unsupported or
4015
+ oversized file, a password, a scanned PDF, a corrupt or hostile package and a range outside the document; one metric
4016
+ row records the kind, the estimated tokens of the whole text and of the answer."""
4017
+ started = time.monotonic()
4018
+ unknown = sorted(set(arguments) - {"path", "range", "max_chars", "info_only"})
4019
+ if unknown:
4020
+ raise ValueError(f"read_document does not take {', '.join(unknown)}")
4021
+ try:
4022
+ text = document_read.read(arguments.get("path"), arguments.get("range") or None,
4023
+ arguments.get("max_chars", document_read.DEFAULT_MAX_CHARS),
4024
+ bool(arguments.get("info_only")))
4025
+ except Exception as exc:
4026
+ _metric("read_document", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2))
4027
+ raise
4028
+ _metric("read_document", kind=os.path.splitext(str(arguments.get("path")))[1].lower(),
4029
+ answer_tokens=document_read.estimate_tokens(text), elapsed_s=round(time.monotonic() - started, 2))
4030
+ return _sanitize_text(text)
4031
+
4032
+
3982
4033
  _TOOL_HANDLERS = {
4034
+ "read_document": _handle_read_document,
3983
4035
  "web_fetch": _handle_web_fetch,
3984
4036
  "project_manage": _handle_project_manage,
3985
4037
  "smart_search": _handle_smart_search,
@@ -4002,7 +4054,8 @@ def _metric(event, **fields):
4002
4054
  _session, client = _session_client()
4003
4055
  try:
4004
4056
  integration_metrics.record(event, client=client_hooks.identify(client) or client.get("name"),
4005
- client_version=client.get("version"), **fields)
4057
+ client_version=client.get("version"),
4058
+ **({"requester": requester.current()} if requester.current() else {}), **fields)
4006
4059
  except (OSError, ValueError) as exc:
4007
4060
  _log(f"integration metric not saved: {exc}")
4008
4061
 
@@ -4016,6 +4069,9 @@ def _dispatch_tool_call(params, session_id=None):
4016
4069
  if handler is None:
4017
4070
  return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
4018
4071
  token = _MCP_SESSION.set(session_id)
4072
+ who = None
4073
+ if name in requester.WEB_TOOLS:
4074
+ who = requester.CURRENT.set(requester.claim(session_id, _session_client()[1].get("name"), name, arguments))
4019
4075
  try:
4020
4076
  with model_health.tool_call(name) as failures:
4021
4077
  try:
@@ -4031,6 +4087,8 @@ def _dispatch_tool_call(params, session_id=None):
4031
4087
  result["content"][0]["text"] += _sanitize_text(warning)
4032
4088
  return result
4033
4089
  finally:
4090
+ if who is not None:
4091
+ requester.CURRENT.reset(who)
4034
4092
  _MCP_SESSION.reset(token)
4035
4093
 
4036
4094
 
package/tool_redirect.py CHANGED
@@ -10,6 +10,7 @@ the PreToolUse is rewritten to a no-op that always succeeds (a grep with no matc
10
10
  replacement: its shell call is rewritten to print a file holding the answer, which Codex only applies with `allow`.
11
11
  """
12
12
  import concurrent.futures
13
+ import contextvars
13
14
  import os
14
15
  import threading
15
16
  import time
@@ -40,7 +41,7 @@ class Jobs:
40
41
  for old in [k for k, (at, _f) in self._futures.items() if now - at > RESULT_TTL_S]:
41
42
  del self._futures[old]
42
43
  if key not in self._futures:
43
- self._futures[key] = (now, _POOL.submit(self._runners[kind], arguments))
44
+ self._futures[key] = (now, _POOL.submit(contextvars.copy_context().run, self._runners[kind], arguments))
44
45
 
45
46
  def wait(self, key, timeout):
46
47
  """The text of key's call when it finished within timeout seconds, else None (the call keeps running and a
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.43"
4
+ VERSION = "0.9.45"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
@@ -24,14 +24,17 @@ _LOCK = threading.Lock()
24
24
  OUTCOMES = ("ok", "failed", "rate_limited", "captcha", "paused")
25
25
 
26
26
 
27
- def record(tier_name, success, outcome=None):
27
+ def record(tier_name, success, outcome=None, requester=None):
28
28
  """One tier call: outcome tells a provider 429 (rate_limited), a captcha and a self-imposed quota pause (paused,
29
- left out of success rates) apart from an ordinary failure, so quota pressure can be read from the log."""
29
+ left out of success rates) apart from an ordinary failure, so quota pressure can be read from the log. requester
30
+ (requester.claim) says which session asked, never what."""
30
31
  outcome = outcome or ("ok" if success else "failed")
31
32
  if outcome not in OUTCOMES:
32
33
  raise ValueError(f"Unknown web search outcome: {outcome}")
33
34
  os.makedirs(os.path.dirname(HEALTH_PATH), exist_ok=True)
34
35
  entry = {"ts": time.time(), "tier": tier_name, "success": bool(success), "outcome": outcome}
36
+ if requester:
37
+ entry["requester"] = requester
35
38
  with _LOCK:
36
39
  with open(HEALTH_PATH, "a", encoding="utf-8") as f:
37
40
  f.write(json.dumps(entry, ensure_ascii=False) + "\n")