@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +251 -0
  32. package/install_runtime.py +249 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +35 -6
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
package/router.py ADDED
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env python3
2
+ """Roteador de tool calls do Smart Tool. V1 não tem classificador mecânico de "tem
3
+ escopo": toda chamada de tool nativa casada pelo hook passa por aqui, que pergunta a um
4
+ modelo pequeno se deve deixar passar ou redirecionar pra `smart_search`. Cada decisão é
5
+ logada em `router-metrics.jsonl` pra orientar um classificador mais fino depois, com
6
+ base em uso real — não numa suposição a priori.
7
+ """
8
+ import json
9
+ import os
10
+ import paths
11
+ import re
12
+ import time
13
+
14
+ import atomic_io
15
+ import model_client
16
+ import index_scope
17
+ import indexer
18
+ import web_search_adapters
19
+
20
+ CLIENT = None
21
+ METRICS_PATH = os.path.join(paths.DATA_DIR, "router-metrics.jsonl")
22
+
23
+ _SYSTEM_PROMPT = (
24
+ "You decide whether a tool call (native: Grep, Glob, Read, or Bash running a "
25
+ "search/read command such as grep/find/cat/ls -R/rg) should execute directly or "
26
+ "be redirected to an indexed semantic search (smart_search), because it is "
27
+ "potentially expensive or imprecise (e.g. no path filter, a large directory like "
28
+ "node_modules, an unscoped recursive command, or a question better answered by "
29
+ "semantic search than by regex/raw listing). For Bash, judge by the command's "
30
+ "content (the tool_input 'command' field): only consider redirecting commands "
31
+ "whose purpose is to search/read source code — never redirect commands with any "
32
+ "other effect (build, git, install, running a script, etc.), even if grep/cat/find "
33
+ "appear mid-pipeline.\n\n"
34
+ "The 'target' object is measured on disk right now — prefer it over any guess you "
35
+ "would make from the path string alone:\n"
36
+ "- target.files / target.truncated: files found under the target, ignoring the "
37
+ "directories listed in target.heavy_dirs and target.ignored_by_default. 'truncated': "
38
+ "true means the scan hit its budget, so the tree is big.\n"
39
+ "- target.heavy_dirs: dependency/build trees present under the target (node_modules, "
40
+ ".venv, dist...). A raw recursive search may reach them, unless the project's ignore "
41
+ "rules already exclude them.\n"
42
+ "- target.ignored_by_default: directories the native search tool skips on its own "
43
+ "(.git, __pycache__, dot-directories). Their presence is NOT a cost signal.\n"
44
+ "- target.scope: present when the target could not be derived from the call itself, "
45
+ "so the measurement describes the session directory and not what the command reads.\n"
46
+ "- target.outside_project: the target is outside the project root; no index covers "
47
+ "it, so never redirect.\n"
48
+ "- target.indexed: whether a semantic index already exists for this project. When it "
49
+ "is false, redirecting forces a cold index build inside the call (embedding every "
50
+ "file, tens of seconds) — only worth it when the search really is broad.\n"
51
+ "- target.missing: true when the path does not exist; never redirect those, the "
52
+ "native tool's own error is the useful answer.\n\n"
53
+ "Reading one specific file is never redirected. When in doubt, answer 'allow'. "
54
+ "Call submit_decision with your answer."
55
+ )
56
+
57
+ # Toda chamada de Grep/Glob/Read/Bash do agente espera por esta decisão, e o desfecho de
58
+ # falha é `allow` — então esperar mais só atrasa o agente sem mudar o resultado. O valor
59
+ # pressupõe `router_model` sem cadeia de raciocínio: um modelo "thinking" gasta mais de mil
60
+ # tokens numa decisão binária e estoura isto sempre, abrindo o breaker e deixando o
61
+ # roteamento desligado de forma permanente.
62
+ DECISION_TIMEOUT_S = 8
63
+
64
+ _SCAN_MAX_FILES = 4000
65
+ _SCAN_MAX_SECONDS = 0.25
66
+ # Percorridos pela tool nativa: a presença deles é sinal de custo de verdade.
67
+ _HEAVY_DIR_NAMES = frozenset({
68
+ "node_modules", ".venv", "venv", "vendor", "target", "dist", "build", "out",
69
+ ".next", ".nuxt", ".gradle", "Pods",
70
+ })
71
+ # Grep/Glob do Claude Code são ripgrep, que já pula estes por padrão — listá-los junto
72
+ # dos de cima superestimaria o custo e enviesaria a decisão pra `redirect`.
73
+ _IGNORED_BY_DEFAULT_DIRS = frozenset({".git", "__pycache__", ".mypy_cache", ".pytest_cache"})
74
+ _PATH_FIELDS = ("path", "file_path", "notebook_path")
75
+
76
+
77
+ def _resolve_path(raw, cwd):
78
+ """Path do `tool_input` normalizado para o disco. Relativo resolve contra o `cwd` do
79
+ payload (não o do processo do hook, que é outro), e a forma MSYS `/c/...` — que o
80
+ agente emite com frequência em shell POSIX no Windows — é convertida, senão
81
+ `os.path.exists` responde `False` sobre um arquivo que existe."""
82
+ if not raw:
83
+ return None
84
+ path = raw.strip().replace("\\", "/")
85
+ msys = re.fullmatch(r"/([A-Za-z])(/.*)?", path)
86
+ if msys:
87
+ path = f"{msys.group(1).upper()}:{msys.group(2) or '/'}"
88
+ if not os.path.isabs(path) and cwd:
89
+ path = os.path.join(cwd, path)
90
+ return os.path.normpath(path)
91
+
92
+
93
+ def _target_path(tool_input, cwd):
94
+ for field in _PATH_FIELDS:
95
+ value = tool_input.get(field)
96
+ if isinstance(value, str) and value.strip():
97
+ return value
98
+ return None
99
+
100
+
101
+ def _command_paths(command, cwd):
102
+ """Paths que aparecem como argumento do comando e existem em disco. Sem isto, um
103
+ `grep -n foo arquivo.py` num monorepo seria medido como se varresse o repo todo."""
104
+ found = []
105
+ previous, base = "", cwd
106
+ for token in re.findall(r'"([^"]+)"|\'([^\']+)\'|(\S+)', command or ""):
107
+ raw = next((t for t in token if t), "")
108
+ after_cd, previous = previous.lower() in ("cd", "pushd", "set-location", "sl"), raw
109
+ if after_cd:
110
+ # The directory of a `cd` is where the command runs (base for what follows), not what it searches.
111
+ base = _resolve_path(raw, base) or base
112
+ continue
113
+ if not raw or raw.startswith("-"):
114
+ continue
115
+ resolved = _resolve_path(raw, base)
116
+ if resolved and os.path.exists(resolved):
117
+ found.append(resolved)
118
+ return found
119
+
120
+
121
+ def _scan_target(path):
122
+ files = 0
123
+ heavy = set()
124
+ ignored = set()
125
+ truncated = False
126
+ deadline = time.monotonic() + _SCAN_MAX_SECONDS
127
+ for dirpath, dirnames, filenames in os.walk(path):
128
+ for name in list(dirnames):
129
+ if name in _HEAVY_DIR_NAMES:
130
+ heavy.add(name)
131
+ dirnames.remove(name)
132
+ elif name in _IGNORED_BY_DEFAULT_DIRS or name.startswith("."):
133
+ ignored.add(name)
134
+ dirnames.remove(name)
135
+ for _ in filenames:
136
+ files += 1
137
+ # Testar o orçamento só entre diretórios deixaria um único diretório com
138
+ # centenas de milhares de entradas passar por cima dele.
139
+ if files > _SCAN_MAX_FILES or time.monotonic() > deadline:
140
+ return {"files": files, "heavy_dirs": sorted(heavy),
141
+ "ignored_by_default": sorted(ignored), "truncated": True}
142
+ if time.monotonic() > deadline:
143
+ truncated = True
144
+ break
145
+ return {"files": files, "heavy_dirs": sorted(heavy),
146
+ "ignored_by_default": sorted(ignored), "truncated": truncated}
147
+
148
+
149
+ def _index_facts(project_root):
150
+ try:
151
+ # A identidade é compartilhada; índices legados continuam visíveis até a migração.
152
+ db = indexer.existing_db_path(project_root)
153
+ if db and os.path.isfile(db):
154
+ return {"indexed": True, "index_kib": os.path.getsize(db) // 1024}
155
+ return {"indexed": False}
156
+ except OSError:
157
+ return {"indexed": False}
158
+
159
+
160
+ def _target_facts(tool_name, tool_input, cwd):
161
+ """Fatos medidos em disco sobre o alvo da chamada. Roda antes de toda tool call
162
+ casada pelo hook, então é limitado por tempo e por contagem, não desce em diretório
163
+ pesado e nunca sai da raiz do projeto. Sem isto o modelo julga custo só pelo texto do
164
+ path."""
165
+ raw = _target_path(tool_input, cwd)
166
+ if raw is None and tool_name == "Bash":
167
+ candidates = _command_paths(tool_input.get("command"), cwd)
168
+ if candidates:
169
+ raw = candidates[0]
170
+ scope_note = None
171
+ if raw is None:
172
+ raw, scope_note = cwd, "session cwd, not derived from the command"
173
+ path = _resolve_path(raw, cwd)
174
+ if not path:
175
+ return {}
176
+ facts = {"path": _display_path(path, cwd)}
177
+ if scope_note:
178
+ facts["scope"] = scope_note
179
+ # Fora da raiz do projeto nada é medido: varrer `C:\` ou o perfil do usuário antes de
180
+ # uma tool call custaria mais que a própria chamada, e a decisão seria `allow` igual.
181
+ if cwd and not index_scope._within_root(cwd, path):
182
+ facts["outside_project"] = True
183
+ return facts
184
+ try:
185
+ if os.path.isfile(path):
186
+ facts["is_file"] = True
187
+ facts["kib"] = os.path.getsize(path) // 1024
188
+ return facts
189
+ if not os.path.isdir(path):
190
+ # Só afirma ausência quando o path é inequívoco; adivinhar `missing` faria o
191
+ # pré-filtro liberar a chamada com um motivo falso no dataset de calibração.
192
+ facts["missing"] = True
193
+ return facts
194
+ facts.update(_scan_target(path))
195
+ except OSError as exc:
196
+ facts["scan_error"] = str(exc)
197
+ return facts
198
+ facts.update(_index_facts(cwd or path))
199
+ return facts
200
+
201
+
202
+ def _display_path(path, cwd):
203
+ """Relativo ao projeto quando possível: o path absoluto carrega o nome do usuário do
204
+ Windows e vai inteiro no prompt enviado ao gateway."""
205
+ if cwd:
206
+ try:
207
+ rel = os.path.relpath(path, cwd)
208
+ if not rel.startswith(".."):
209
+ return rel.replace(os.sep, "/")
210
+ except ValueError:
211
+ pass
212
+ return os.path.basename(path) or path
213
+
214
+ _DECISION_TOOL = {
215
+ "type": "function",
216
+ "function": {
217
+ "name": "submit_decision",
218
+ "description": "Submit the routing decision for this tool call.",
219
+ "parameters": {
220
+ "type": "object",
221
+ "properties": {
222
+ "decision": {"type": "string", "enum": ["allow", "redirect"]},
223
+ "reason": {"type": "string", "description": "Short justification, one sentence."},
224
+ },
225
+ "required": ["decision", "reason"],
226
+ },
227
+ },
228
+ }
229
+
230
+
231
+ METRICS_MAX_BYTES = 4 * 1024 * 1024
232
+ _METRIC_MAX_FIELD_CHARS = 600
233
+ _SECRET_ARG_RE = re.compile(
234
+ r"(?i)(authorization[:=]\s*(?:bearer|basic|token)?\s*|bearer\s+|"
235
+ r"--?(?:with-)?token[= ]|api[_-]?key[= ]|password[= ]|passwd[= ]|secret[= ]|-p\s+)"
236
+ r"[^\s\"']+"
237
+ )
238
+ _URL_CREDENTIAL_RE = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^\s/@]+:[^\s/@]+@")
239
+
240
+
241
+ def _redacted_input(tool_input):
242
+ """Cópia do `tool_input` segura para persistir. O arquivo é append-only, fica em claro
243
+ no perfil do usuário e é legível pelo próprio agente; um `Bash` com credencial na
244
+ linha de comando (`curl -H "Authorization: Bearer ..."`, `psql postgres://u:p@h`) não
245
+ pode virar registro permanente. O campo é truncado porque heredoc inteiro no JSONL
246
+ passa do tamanho em que o append concorrente deixa de ser atômico."""
247
+ if not isinstance(tool_input, dict):
248
+ return tool_input
249
+ safe = {}
250
+ for key, value in tool_input.items():
251
+ if not isinstance(value, str):
252
+ safe[key] = value
253
+ continue
254
+ text = _SECRET_ARG_RE.sub(lambda m: m.group(1) + "***", value)
255
+ text = _URL_CREDENTIAL_RE.sub(lambda m: m.group(1) + "***:***@", text)
256
+ if len(text) > _METRIC_MAX_FIELD_CHARS:
257
+ text = text[:_METRIC_MAX_FIELD_CHARS] + f"...(+{len(text) - _METRIC_MAX_FIELD_CHARS} chars)"
258
+ safe[key] = text
259
+ return safe
260
+
261
+
262
+ def _log_decision(tool_name, tool_input, decision, reason, extra=None):
263
+ record = {
264
+ "ts": time.time(),
265
+ "tool_name": tool_name,
266
+ "tool_input": _redacted_input(tool_input),
267
+ "decision": decision,
268
+ "reason": reason,
269
+ **({"client": CLIENT} if CLIENT else {}),
270
+ }
271
+ record.update(extra or {})
272
+ try:
273
+ os.makedirs(os.path.dirname(METRICS_PATH), exist_ok=True)
274
+ atomic_io.rotate(METRICS_PATH, METRICS_MAX_BYTES)
275
+ with open(METRICS_PATH, "a", encoding="utf-8") as f:
276
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
277
+ except OSError:
278
+ # Métrica é para calibrar depois, não para a decisão: disco cheio ou arquivo
279
+ # travado não pode derrubar uma tool call do agente.
280
+ pass
281
+
282
+
283
+ def log_unavailable(tool_name, tool_input, exc):
284
+ """Registra o `allow` por indisponibilidade na mesma trilha das decisões reais. Sem
285
+ isto a taxa de falha do roteador é invisível: a métrica só teria as chamadas em que
286
+ o modelo respondeu, e a leitura de `redirect`/`allow` sairia enviesada."""
287
+ _log_decision(
288
+ tool_name, tool_input, "allow", f"{type(exc).__name__}: {exc}",
289
+ extra={"unavailable": True},
290
+ )
291
+
292
+
293
+ # Verbo ancorado no início do comando ou de um segmento (`|`, `;`, `&&`, `$(`): casar por
294
+ # substring solta faria qualquer palavra inglesa dentro de um heredoc Python contar como
295
+ # busca. A ausência de verbo é prova mecânica de que o `command` não busca código.
296
+ _SEARCH_VERB_RE = re.compile(
297
+ # Início, separador de segmento, ou logo depois de um invocador de shell
298
+ # (`powershell -Command`, `bash -c`, `cmd /c`), que é como o verbo real costuma
299
+ # aparecer no meio de um comando em máquina Windows.
300
+ r"(?:^|[|;&]|\$\(|`|-c\s|-Command\s|/c\s|/k\s)\s*(?:sudo\s+|command\s+)?"
301
+ r"(grep|egrep|fgrep|rg|ripgrep|find|findstr|fd|cat|type|head|tail|less|more|"
302
+ r"ls|dir|awk|sed|ack|ag|select-string|sls|get-content|gc|get-childitem|gci)\b",
303
+ re.IGNORECASE,
304
+ )
305
+ _READ_TOOLS = frozenset({"Read", "NotebookRead"})
306
+
307
+ BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
308
+ BREAKER_FAILURES = 3
309
+ BREAKER_COOLDOWN_S = 300
310
+
311
+
312
+ def _mechanical_decision(tool_name, tool_input):
313
+ """`(decisão, motivo)` quando a resposta não depende de julgamento nem de tocar o
314
+ disco, ou `None`.
315
+
316
+ Medido em 1973 decisões reais: 97,8% eram `allow`, e 1512 delas (77%) caíam nestes
317
+ casos — cada uma custando uma ida ao gateway antes de toda tool call do agente. Os
318
+ dois únicos `Read` redirecionados nesse histórico eram decisões erradas (um deles com
319
+ `offset`/`limit` explícitos, onde só as linhas pedidas servem)."""
320
+ if tool_name in _READ_TOOLS:
321
+ return "allow", "Reading a specific file is never replaced by semantic search."
322
+ if tool_name == "Bash":
323
+ command = tool_input.get("command")
324
+ if not isinstance(command, str) or not command.strip():
325
+ return "allow", "Bash without a readable command."
326
+ if not _SEARCH_VERB_RE.search(command):
327
+ return "allow", "The command neither searches nor reads source code."
328
+ return None
329
+
330
+
331
+ def _mechanical_decision_from_facts(facts):
332
+ if facts.get("missing"):
333
+ return "allow", "The path does not exist: the native tool's error is the useful answer."
334
+ if facts.get("is_file"):
335
+ return "allow", "Target is a single file, already scoped."
336
+ if facts.get("outside_project"):
337
+ return "allow", "Target is outside the project root: no index covers it."
338
+ return None
339
+
340
+
341
+ def _breaker_state():
342
+ try:
343
+ with open(BREAKER_PATH, "r", encoding="utf-8") as f:
344
+ state = json.load(f)
345
+ return state if isinstance(state, dict) else {}
346
+ except (OSError, json.JSONDecodeError):
347
+ return {}
348
+
349
+
350
+ def _breaker_write(state):
351
+ # tmp + replace: processos concorrentes gravam aqui (cada tool call é um processo
352
+ # novo), e um `open("w")` truncado no meio faz o leitor ver arquivo vazio — ou seja,
353
+ # breaker fechado justo quando ele deveria estar aberto. Sem `fsync`: perder a última
354
+ # escrita aqui é inofensivo e este caminho roda antes de cada tool call.
355
+ try:
356
+ os.makedirs(os.path.dirname(BREAKER_PATH), exist_ok=True)
357
+ tmp_path = f"{BREAKER_PATH}.tmp.{os.getpid()}"
358
+ with open(tmp_path, "w", encoding="utf-8") as f:
359
+ json.dump(state, f)
360
+ os.replace(tmp_path, BREAKER_PATH)
361
+ except OSError:
362
+ pass
363
+
364
+
365
+ def breaker_open_until():
366
+ """Instante até quando o roteador está desligado por falhas recentes, ou 0.
367
+
368
+ Gateway lento é o pior caso de custo: sem isto, cada tool call paga o timeout inteiro
369
+ para terminar em `allow` de qualquer forma. O estado é compartilhado em arquivo porque
370
+ cada chamada do hook é um processo novo — nada sobrevive em memória."""
371
+ now = time.time()
372
+ try:
373
+ until = float(_breaker_state().get("open_until") or 0)
374
+ except (TypeError, ValueError):
375
+ return 0
376
+ # Teto pelo próprio cooldown: um salto de relógio (resume de VM, correção de fuso) no
377
+ # instante da gravação deixaria um `open_until` no futuro distante, e o roteador
378
+ # ficaria desligado pra sempre — sem erro em lugar nenhum, porque com o breaker aberto
379
+ # nunca há sucesso pra reabrir.
380
+ if until - now > BREAKER_COOLDOWN_S:
381
+ return 0
382
+ return until if now < until else 0
383
+
384
+
385
+ def note_failure():
386
+ state = _breaker_state()
387
+ now = time.time()
388
+ # Janela de tempo em vez de contador: cada hook é um processo, e três falhas
389
+ # simultâneas leriam todas `failures=0` e gravariam `1`, nunca alcançando o limite.
390
+ # Falha de semana passada também não pode somar com a de hoje.
391
+ recent = [t for t in state.get("failures", []) if isinstance(t, (int, float)) and now - t < BREAKER_COOLDOWN_S]
392
+ recent.append(now)
393
+ state["failures"] = recent[-BREAKER_FAILURES:]
394
+ if len(recent) >= BREAKER_FAILURES:
395
+ state["open_until"] = now + BREAKER_COOLDOWN_S
396
+ state["failures"] = []
397
+ _breaker_write(state)
398
+
399
+
400
+ def note_success():
401
+ state = _breaker_state()
402
+ if not state:
403
+ return
404
+ # Um cooldown aberto por outro processo sobrevive: quem leu o estado antes da abertura
405
+ # e teve sucesso não pode apagar a decisão de quem já viu o gateway falhar 3 vezes.
406
+ open_until = breaker_open_until()
407
+ _breaker_write({"open_until": open_until} if open_until else {})
408
+
409
+
410
+ def decide(tool_name, tool_input, router_model, cwd=None):
411
+ safe_input = tool_input if isinstance(tool_input, dict) else {}
412
+
413
+ # Ordem importa: tudo que decide sem tocar disco vem antes do snapshot, e o breaker
414
+ # vem antes dele também — em cooldown o desfecho é `allow` de qualquer forma, então
415
+ # percorrer diretório ali seria I/O puro por nada.
416
+ mechanical = _mechanical_decision(tool_name, safe_input)
417
+ if mechanical:
418
+ decision, reason = mechanical
419
+ _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
420
+ return decision, reason
421
+
422
+ if breaker_open_until():
423
+ reason = "Router in cooldown after recent gateway failures."
424
+ _log_decision(tool_name, tool_input, "allow", reason, extra={"breaker": True})
425
+ return "allow", reason
426
+
427
+ facts = _target_facts(tool_name, safe_input, cwd)
428
+ mechanical = _mechanical_decision_from_facts(facts)
429
+ if mechanical:
430
+ decision, reason = mechanical
431
+ _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
432
+ return decision, reason
433
+
434
+ started = time.monotonic()
435
+ try:
436
+ # O orçamento inteiro do hook no host é de poucos segundos: obter token não pode
437
+ # consumir mais que a própria decisão.
438
+ token = model_client.get_token()
439
+ response = model_client.fetch(
440
+ "/v1/chat/completions", token, method="POST", timeout=DECISION_TIMEOUT_S,
441
+ body={
442
+ "model": router_model,
443
+ "messages": [
444
+ {"role": "system", "content": _SYSTEM_PROMPT},
445
+ {"role": "user", "content": json.dumps({
446
+ # Mesma redação do log: a linha de comando também vai pro gateway.
447
+ "tool_name": tool_name, "tool_input": _redacted_input(safe_input),
448
+ "target": facts,
449
+ }, ensure_ascii=False)},
450
+ ],
451
+ "tools": [_DECISION_TOOL],
452
+ "tool_choice": {"type": "function", "function": {"name": "submit_decision"}},
453
+ "temperature": 0,
454
+ },
455
+ )
456
+ tool_call = response["choices"][0]["message"]["tool_calls"][0]
457
+ args = json.loads(tool_call["function"]["arguments"])
458
+ except Exception:
459
+ note_failure()
460
+ raise
461
+ note_success()
462
+ decision = args.get("decision", "allow")
463
+ reason = args.get("reason", "")
464
+ _log_decision(tool_name, tool_input, decision, reason, extra={"ms": round((time.monotonic() - started) * 1000)})
465
+ return decision, reason
466
+
467
+
468
+ def _web_search_tier_system_prompt():
469
+ # Montado a partir de web_search_adapters.ADAPTERS (campo "hint") em vez de fixo:
470
+ # um adaptador novo/removido passa a valer aqui sem editar este arquivo.
471
+ lines = [
472
+ "You choose which tier to try FIRST for a web search request, given the query, an "
473
+ "optional scope hint, and the recent success rate of each tier (null means no data "
474
+ "yet, treat as neutral). Tiers, in default preference order:",
475
+ ]
476
+ for adapter in web_search_adapters.ADAPTERS:
477
+ lines.append(f"- '{adapter['name']}': {adapter['hint']}")
478
+ lines.append(
479
+ "Prefer the default order unless a later tier's recent success rate is clearly "
480
+ "better than an earlier one's. Call submit_tier."
481
+ )
482
+ return "\n".join(lines)
483
+
484
+
485
+ def _tier_decision_tool():
486
+ return {
487
+ "type": "function",
488
+ "function": {
489
+ "name": "submit_tier",
490
+ "description": "Submit which tier to try first for this web search.",
491
+ "parameters": {
492
+ "type": "object",
493
+ "properties": {
494
+ "tier": {"type": "string", "enum": web_search_adapters.DEFAULT_TIER_ORDER},
495
+ "reason": {"type": "string", "description": "Short justification, one sentence."},
496
+ },
497
+ "required": ["tier", "reason"],
498
+ },
499
+ },
500
+ }
501
+
502
+
503
+ def decide_web_search_tier(query, scope, health_summary, router_model):
504
+ token = model_client.get_token()
505
+ user_content = json.dumps(
506
+ {"query": query, "scope": scope, "recent_success_rate": health_summary},
507
+ ensure_ascii=False,
508
+ )
509
+ response = model_client.fetch(
510
+ "/v1/chat/completions", token, method="POST",
511
+ body={
512
+ "model": router_model,
513
+ "messages": [
514
+ {"role": "system", "content": _web_search_tier_system_prompt()},
515
+ {"role": "user", "content": user_content},
516
+ ],
517
+ "tools": [_tier_decision_tool()],
518
+ "tool_choice": {"type": "function", "function": {"name": "submit_tier"}},
519
+ "temperature": 0,
520
+ },
521
+ )
522
+ tool_call = response["choices"][0]["message"]["tool_calls"][0]
523
+ args = json.loads(tool_call["function"]["arguments"])
524
+ tier = args.get("tier", web_search_adapters.DEFAULT_TIER_ORDER[0])
525
+ reason = args.get("reason", "")
526
+ return tier, reason
527
+
528
+
529
+ _SEARCH_SUFFICIENCY_TOOL = {
530
+ "type": "function",
531
+ "function": {
532
+ "name": "assess_search",
533
+ "description": "Decide if additional search providers are needed for this query.",
534
+ "parameters": {
535
+ "type": "object",
536
+ "properties": {
537
+ "need_more": {"type": "boolean"},
538
+ "reason": {"type": "string"},
539
+ },
540
+ "required": ["need_more", "reason"],
541
+ },
542
+ },
543
+ }
544
+
545
+
546
+ def assess_web_search_results(query, results, requested_num, scope, model, timeout=12):
547
+ """Uma decisão curta sobre relevância/cobertura antes do fallback caro."""
548
+ token = model_client.get_token()
549
+ evidence = [
550
+ {"title": str(row.get("title") or "")[:180],
551
+ "url": str(row.get("url") or "")[:240],
552
+ "snippet": str(row.get("snippet") or "")[:420]}
553
+ for row in results[:min(requested_num, 10)]
554
+ ]
555
+ response = model_client.fetch(
556
+ "/v1/chat/completions", token, method="POST", timeout=timeout,
557
+ body={
558
+ "model": model,
559
+ "messages": [
560
+ {"role": "system", "content": (
561
+ "You assess whether a QUICK web search has enough relevant sources to answer "
562
+ "the user's actual query. Call assess_search. Set need_more=true only when "
563
+ "the central requested fact or aspect is absent, results are mostly off-topic "
564
+ "or duplicative, or time-sensitive results are clearly stale. Do not demand "
565
+ "exhaustive depth or primary sources unless the query asks for them. A few "
566
+ "good snippets can be sufficient; the calling agent writes the final answer."
567
+ )},
568
+ {"role": "user", "content": json.dumps({
569
+ "query": query, "scope": scope, "requested_results": requested_num,
570
+ "results": evidence,
571
+ }, ensure_ascii=False)},
572
+ ],
573
+ "tools": [_SEARCH_SUFFICIENCY_TOOL],
574
+ "tool_choice": {"type": "function", "function": {"name": "assess_search"}},
575
+ "temperature": 0,
576
+ },
577
+ )
578
+ call = response["choices"][0]["message"]["tool_calls"][0]
579
+ args = json.loads(call["function"]["arguments"])
580
+ if not isinstance(args.get("need_more"), bool):
581
+ raise ValueError("Search evaluation did not return a boolean need_more.")
582
+ return args["need_more"], str(args.get("reason") or "")