@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.
- package/CHANGELOG.md +34 -0
- package/README.md +17 -2
- package/browser_control.py +71 -15
- package/client_hooks.py +1 -1
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/hook_decision.py +9 -1
- package/lazy_mcp_proxy.py +185 -0
- package/package.json +1 -1
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/smart_tool_daemon.py +68 -10
- package/tool_redirect.py +2 -1
- package/version.py +1 -1
- package/web_search_health.py +5 -2
package/research_metrics.py
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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:
|
package/smart_tool_daemon.py
CHANGED
|
@@ -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"),
|
|
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
package/web_search_health.py
CHANGED
|
@@ -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")
|