pythinker-code 2.2.0__py3-none-any.whl → 2.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.
Files changed (90) hide show
  1. pythinker_code/CHANGELOG.md +21 -0
  2. pythinker_code/acp/host.py +3 -0
  3. pythinker_code/acp/session.py +7 -1
  4. pythinker_code/auth/oauth.py +18 -0
  5. pythinker_code/auth/openai.py +12 -0
  6. pythinker_code/auth/platforms.py +9 -0
  7. pythinker_code/hooks/engine.py +13 -1
  8. pythinker_code/hooks/runner.py +3 -0
  9. pythinker_code/soul/btw.py +6 -0
  10. pythinker_code/soul/pythinkersoul.py +34 -4
  11. pythinker_code/soul/toolset.py +42 -13
  12. pythinker_code/subagents/runner.py +7 -1
  13. pythinker_code/telemetry/config.py +29 -0
  14. pythinker_code/telemetry/errors.py +107 -0
  15. pythinker_code/telemetry/otel.py +24 -2
  16. pythinker_code/tools/agent/__init__.py +3 -0
  17. pythinker_code/tools/ask_user/__init__.py +4 -1
  18. pythinker_code/tools/file/glob.py +3 -0
  19. pythinker_code/tools/file/grep_local.py +3 -0
  20. pythinker_code/tools/file/read.py +3 -0
  21. pythinker_code/tools/file/read_media.py +3 -0
  22. pythinker_code/tools/file/replace.py +3 -0
  23. pythinker_code/tools/file/write.py +3 -0
  24. pythinker_code/tools/plan/__init__.py +4 -1
  25. pythinker_code/tools/plan/enter.py +4 -1
  26. pythinker_code/tools/shell/__init__.py +6 -0
  27. pythinker_code/ui/shell/slash.py +121 -0
  28. pythinker_code/web/static/assets/{_baseUniq-3b9Vjh1L.js → _baseUniq-DYwtr3m4.js} +1 -1
  29. pythinker_code/web/static/assets/{arc-DR3CXZeR.js → arc-CNhBgyVb.js} +1 -1
  30. pythinker_code/web/static/assets/{architectureDiagram-VXUJARFQ-BQ0-qSoc.js → architectureDiagram-VXUJARFQ-DpvaxB3Y.js} +1 -1
  31. pythinker_code/web/static/assets/{blockDiagram-VD42YOAC-Kd0tjFCT.js → blockDiagram-VD42YOAC-IlYHIkrW.js} +1 -1
  32. pythinker_code/web/static/assets/{c4Diagram-YG6GDRKO-Cw4rqOBn.js → c4Diagram-YG6GDRKO-D_jGrUIu.js} +1 -1
  33. pythinker_code/web/static/assets/channel-BPOuE91b.js +1 -0
  34. pythinker_code/web/static/assets/{chunk-4BX2VUAB-CrDv9tak.js → chunk-4BX2VUAB-uYRqFG6q.js} +1 -1
  35. pythinker_code/web/static/assets/{chunk-55IACEB6-DmOcpRpp.js → chunk-55IACEB6-5K_8Tvtf.js} +1 -1
  36. pythinker_code/web/static/assets/{chunk-B4BG7PRW-ChaFy2yE.js → chunk-B4BG7PRW-BAp2tokd.js} +1 -1
  37. pythinker_code/web/static/assets/{chunk-DI55MBZ5-BqW-gG3F.js → chunk-DI55MBZ5-C3ICALbg.js} +1 -1
  38. pythinker_code/web/static/assets/{chunk-FMBD7UC4-CubqcuRB.js → chunk-FMBD7UC4-B3ntDoat.js} +1 -1
  39. pythinker_code/web/static/assets/{chunk-QN33PNHL-1SSU3xPb.js → chunk-QN33PNHL-Dy8y3fp6.js} +1 -1
  40. pythinker_code/web/static/assets/{chunk-QZHKN3VN-ClwSLfPq.js → chunk-QZHKN3VN-BXmiK1aE.js} +1 -1
  41. pythinker_code/web/static/assets/{chunk-TZMSLE5B-DUb7Xnze.js → chunk-TZMSLE5B-BbI6RHhP.js} +1 -1
  42. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-C1S9FRV4.js +1 -0
  43. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-C1S9FRV4.js +1 -0
  44. pythinker_code/web/static/assets/clone-D2vuslet.js +1 -0
  45. pythinker_code/web/static/assets/{code-block-IT6T5CEO-CU9M5g3e.js → code-block-IT6T5CEO-C09u1ZPS.js} +1 -1
  46. pythinker_code/web/static/assets/{cose-bilkent-S5V4N54A-BpIWTq5c.js → cose-bilkent-S5V4N54A-OfdgQa9b.js} +1 -1
  47. pythinker_code/web/static/assets/{cytoscape.esm-vaP_ZthC.js → cytoscape.esm-BHPoE92Y.js} +1 -1
  48. pythinker_code/web/static/assets/{dagre-6UL2VRFP-DCC7bVEs.js → dagre-6UL2VRFP-Dqsjg8sJ.js} +1 -1
  49. pythinker_code/web/static/assets/{diagram-PSM6KHXK-DC8NtTl0.js → diagram-PSM6KHXK-DxkId0Z8.js} +1 -1
  50. pythinker_code/web/static/assets/{diagram-QEK2KX5R-BZIGC5xL.js → diagram-QEK2KX5R-CkPNihvj.js} +1 -1
  51. pythinker_code/web/static/assets/{diagram-S2PKOQOG-DgJwjM3h.js → diagram-S2PKOQOG-C_N5Jjql.js} +1 -1
  52. pythinker_code/web/static/assets/{erDiagram-Q2GNP2WA-Cmn41NBD.js → erDiagram-Q2GNP2WA-C8_5yrCr.js} +1 -1
  53. pythinker_code/web/static/assets/{flowDiagram-NV44I4VS-D3I6RCYp.js → flowDiagram-NV44I4VS-BfV7xDb8.js} +1 -1
  54. pythinker_code/web/static/assets/{ganttDiagram-JELNMOA3-CKI2c4nb.js → ganttDiagram-JELNMOA3-Cld5kwhV.js} +1 -1
  55. pythinker_code/web/static/assets/{gitGraphDiagram-NY62KEGX-BC0ty1CW.js → gitGraphDiagram-NY62KEGX-F3FwjYQD.js} +1 -1
  56. pythinker_code/web/static/assets/{graph-CEqyonfn.js → graph-BWlEpfBO.js} +1 -1
  57. pythinker_code/web/static/assets/{index-cEE9hGzb.js → index-BqPJMGF-.js} +2 -2
  58. pythinker_code/web/static/assets/{index-n9Qz5PAk.js → index-DYUDz2ym.js} +1 -1
  59. pythinker_code/web/static/assets/{index-B9Mjc3Gm.js → index-DpudRZuI.js} +1 -1
  60. pythinker_code/web/static/assets/{infoDiagram-WHAUD3N6-De2sP9Ub.js → infoDiagram-WHAUD3N6-BJOGXqn7.js} +1 -1
  61. pythinker_code/web/static/assets/{journeyDiagram-XKPGCS4Q-DfKYGnYF.js → journeyDiagram-XKPGCS4Q-BZdlH-JG.js} +1 -1
  62. pythinker_code/web/static/assets/{kanban-definition-3W4ZIXB7-D3yKysrB.js → kanban-definition-3W4ZIXB7-CmgmSsYi.js} +1 -1
  63. pythinker_code/web/static/assets/{layout-DPVEoKKe.js → layout-CWaYhVVo.js} +1 -1
  64. pythinker_code/web/static/assets/{linear-BEfJdq4v.js → linear-Bw6Dncma.js} +1 -1
  65. pythinker_code/web/static/assets/{mermaid-VLURNSYL-BMWN6yQ-.js → mermaid-VLURNSYL-CzjjwzDB.js} +7 -7
  66. pythinker_code/web/static/assets/{mermaid.core-D1FKNjuC.js → mermaid.core-Bb0_1h52.js} +5 -5
  67. pythinker_code/web/static/assets/{min-C_AGskQ6.js → min-Df20Er5m.js} +1 -1
  68. pythinker_code/web/static/assets/{mindmap-definition-VGOIOE7T-s9jf1xzX.js → mindmap-definition-VGOIOE7T-CAe0siLd.js} +1 -1
  69. pythinker_code/web/static/assets/{pieDiagram-ADFJNKIX-D-oXViLF.js → pieDiagram-ADFJNKIX-CLMBAwjU.js} +1 -1
  70. pythinker_code/web/static/assets/{quadrantDiagram-AYHSOK5B-DGUqiK6I.js → quadrantDiagram-AYHSOK5B-B9vjzD3o.js} +1 -1
  71. pythinker_code/web/static/assets/{requirementDiagram-UZGBJVZJ-CVwILSF5.js → requirementDiagram-UZGBJVZJ-Bbjo8TGX.js} +1 -1
  72. pythinker_code/web/static/assets/{sankeyDiagram-TZEHDZUN-D_M1b9Wz.js → sankeyDiagram-TZEHDZUN-xnxkDnDQ.js} +1 -1
  73. pythinker_code/web/static/assets/{sequenceDiagram-WL72ISMW-CBLYLJJI.js → sequenceDiagram-WL72ISMW-qkafBa71.js} +1 -1
  74. pythinker_code/web/static/assets/{stateDiagram-FKZM4ZOC-ZZg0Md3N.js → stateDiagram-FKZM4ZOC-BzTcRJpG.js} +1 -1
  75. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-TyAV0qk_.js +1 -0
  76. pythinker_code/web/static/assets/{timeline-definition-IT6M3QCI-B7D4eU3W.js → timeline-definition-IT6M3QCI-0ls9u7xd.js} +1 -1
  77. pythinker_code/web/static/assets/{treemap-KMMF4GRG-Cy0XtWQy.js → treemap-KMMF4GRG-C1ChVaOv.js} +1 -1
  78. pythinker_code/web/static/assets/{xychartDiagram-PRI3JC2R-BWl1l11O.js → xychartDiagram-PRI3JC2R-Ba9eacUU.js} +1 -1
  79. pythinker_code/web/static/index.html +1 -1
  80. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/METADATA +27 -5
  81. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/RECORD +85 -84
  82. pythinker_code/web/static/assets/channel-CV_6LFjm.js +0 -1
  83. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-DRifCGzI.js +0 -1
  84. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-DRifCGzI.js +0 -1
  85. pythinker_code/web/static/assets/clone-BZw9ajX3.js +0 -1
  86. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-D1YbdDrw.js +0 -1
  87. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/WHEEL +0 -0
  88. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/entry_points.txt +0 -0
  89. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/licenses/LICENSE +0 -0
  90. {pythinker_code-2.2.0.dist-info → pythinker_code-2.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.3.0 (2026-05-09)
6
+
7
+ Telemetry & observability audit.
8
+
9
+ - New `pythinker_code/telemetry/errors.py` helper `report_handled_error(exc, *, site, tool=None, **attrs)` forwards caught-and-rendered exceptions to both Sentry/Bugsink and the OTel `error` event stream. Both forwarding paths are `contextlib.suppress`-wrapped so monitoring can never break the host program.
10
+ - 38 silent-catch sites instrumented across `tools/`, `auth/`, `soul/`, `acp/`, `hooks/`, `subagents/`. Tool failures, OAuth errors, MCP server hiccups, hook callback failures, and subagent crashes now reach Bugsink and SigNoz.
11
+ - `pythinker_code/telemetry/otel.py`: TracerProvider now uses `ParentBased(TraceIdRatioBased(rate))` driven by `PYTHINKER_OTEL_TRACE_SAMPLE_RATE` (default 1.0).
12
+ - New `pythinker.mcp.call` span around every MCPTool RPC.
13
+ - New `/report-error` slash command (aliases `/report`, `/report-error`).
14
+ - `docs/en/reference/telemetry.md` documents the full telemetry contract.
15
+ - `chore(test)`: updated google-genai snapshot for pydantic 2.13.4 + google-genai 2.0.0.
16
+
17
+
18
+ ## 2.2.1 (2026-05-09)
19
+
20
+ CI hardening: macOS binary build is now optional-codesign.
21
+
22
+ - `.github/workflows/release-pythinker-cli.yml`: detect whether `APPLE_CERTIFICATE_P12` and `APPLE_NOTARIZATION_KEY_P8` repo secrets are configured. When they aren't, skip the keychain setup, codesign, and notarization steps and ship an ad-hoc-signed PyInstaller binary instead of failing the whole release. The 2.2.0 release run failed because those secrets were empty in CI; this makes the release matrix all-green even without an Apple Developer cert.
23
+ - `.github/workflows/release-pythinker-cli.yml`: add `skip-existing: true` to `pypa/gh-action-pypi-publish` so re-runs of the release workflow against an already-published version are no-ops instead of HTTP 400 errors.
24
+ - macOS-arm64 binary downloads from the GitHub Release page will now show a Gatekeeper warning on first launch when the secrets aren't configured. Users can clear it with `xattr -d com.apple.quarantine ./pythinker`. PyPI install (`pip install pythinker-code`) is unaffected.
25
+
5
26
  ## 2.2.0 (2026-05-09)
6
27
 
7
28
  Installer UX: animated logo + Windows PATH automation.
@@ -148,6 +148,9 @@ class ACPProcess:
148
148
  )
149
149
  self._feed_output(final_output)
150
150
  except Exception as exc:
151
+ from pythinker_code.telemetry.errors import report_handled_error
152
+
153
+ report_handled_error(exc, site="acp.host.terminal")
151
154
  error_note = f"[acp terminal error] {exc}\n"
152
155
  self._stdout.feed_data(error_note.encode("utf-8", "replace"))
153
156
  if exit_code is None:
@@ -233,6 +233,9 @@ class ACPSession:
233
233
  logger.info("Prompt cancelled by user")
234
234
  return acp.PromptResponse(stop_reason="cancelled")
235
235
  except Exception as e:
236
+ from pythinker_code.telemetry.errors import report_handled_error
237
+
238
+ report_handled_error(e, site="acp.session.prompt")
236
239
  logger.exception("Unexpected error during prompt:")
237
240
  raise acp.RequestError.internal_error({"error": str(e)}) from e
238
241
  finally:
@@ -461,7 +464,10 @@ class ACPSession:
461
464
  # cancelled
462
465
  logger.debug("Permission request cancelled for: {action}", action=request.action)
463
466
  request.resolve("reject")
464
- except Exception:
467
+ except Exception as exc:
468
+ from pythinker_code.telemetry.errors import report_handled_error
469
+
470
+ report_handled_error(exc, site="acp.session.approval")
465
471
  logger.exception("Error handling approval request:")
466
472
  # On error, reject the request
467
473
  request.resolve("reject")
@@ -358,6 +358,9 @@ def _load_from_keyring(key: str) -> OAuthToken | None:
358
358
  try:
359
359
  raw = keyring.get_password(KEYRING_SERVICE, key)
360
360
  except Exception as exc:
361
+ from pythinker_code.telemetry.errors import report_handled_error
362
+
363
+ report_handled_error(exc, site="auth.keyring.read")
361
364
  logger.warning("Failed to read token from keyring: {error}", error=exc)
362
365
  return None
363
366
  if not raw:
@@ -632,6 +635,9 @@ async def login_pythinker_code(
632
635
  try:
633
636
  auth = await request_device_authorization()
634
637
  except Exception as exc:
638
+ from pythinker_code.telemetry.errors import report_handled_error
639
+
640
+ report_handled_error(exc, site="auth.oauth.device_authorize")
635
641
  yield OAuthEvent("error", f"Login failed: {exc}")
636
642
  return
637
643
 
@@ -680,6 +686,9 @@ async def login_pythinker_code(
680
686
  yield OAuthEvent("info", "Device code expired, restarting login...")
681
687
  continue
682
688
  except Exception as exc:
689
+ from pythinker_code.telemetry.errors import report_handled_error
690
+
691
+ report_handled_error(exc, site="auth.oauth.device_poll")
683
692
  yield OAuthEvent("error", f"Login failed: {exc}")
684
693
  return
685
694
  break
@@ -692,6 +701,9 @@ async def login_pythinker_code(
692
701
  try:
693
702
  models = await list_models(platform, token.access_token)
694
703
  except Exception as exc:
704
+ from pythinker_code.telemetry.errors import report_handled_error
705
+
706
+ report_handled_error(exc, site="auth.models.fetch")
695
707
  logger.error("Failed to get models: {error}", error=exc)
696
708
  yield OAuthEvent("error", f"Failed to get models: {exc}")
697
709
  return
@@ -947,6 +959,9 @@ class OAuthManager:
947
959
  try:
948
960
  await self.ensure_fresh(runtime, force=force)
949
961
  except Exception as exc:
962
+ from pythinker_code.telemetry.errors import report_handled_error
963
+
964
+ report_handled_error(exc, site="auth.oauth.refresh.background")
950
965
  logger.warning(
951
966
  "Failed to refresh OAuth token in background: {error}",
952
967
  error=exc,
@@ -1071,6 +1086,9 @@ class OAuthManager:
1071
1086
  except Exception as exc:
1072
1087
  if force:
1073
1088
  raise
1089
+ from pythinker_code.telemetry.errors import report_handled_error
1090
+
1091
+ report_handled_error(exc, site="auth.oauth.refresh")
1074
1092
  logger.warning("Failed to refresh OAuth token: {error}", error=exc)
1075
1093
  from pythinker_code.telemetry import track
1076
1094
 
@@ -612,6 +612,9 @@ async def _finish_chatgpt_login(
612
612
  models = await _discover_chatgpt_models(token.access_token)
613
613
  selected_model, thinking = _select_default_openai_model(models)
614
614
  except Exception as exc:
615
+ from pythinker_code.telemetry.errors import report_handled_error
616
+
617
+ report_handled_error(exc, site="auth.openai.discover_chatgpt_models")
615
618
  yield OAuthEvent("error", f"Failed to discover OpenAI ChatGPT models: {exc}")
616
619
  return
617
620
 
@@ -649,6 +652,9 @@ async def login_openai_browser(
649
652
  code, verifier, redirect_uri = await _wait_for_browser_code(open_browser=open_browser)
650
653
  token_payload = await _exchange_code_for_tokens(code, verifier, redirect_uri)
651
654
  except Exception as exc:
655
+ from pythinker_code.telemetry.errors import report_handled_error
656
+
657
+ report_handled_error(exc, site="auth.openai.browser_login")
652
658
  yield OAuthEvent("error", f"OpenAI browser login failed: {exc}")
653
659
  return
654
660
 
@@ -667,6 +673,9 @@ async def login_openai_headless(config: Config) -> AsyncIterator[OAuthEvent]:
667
673
  try:
668
674
  device_code = await _request_device_code()
669
675
  except Exception as exc:
676
+ from pythinker_code.telemetry.errors import report_handled_error
677
+
678
+ report_handled_error(exc, site="auth.openai.device_start")
670
679
  yield OAuthEvent("error", f"Failed to start OpenAI device login: {exc}")
671
680
  return
672
681
 
@@ -688,6 +697,9 @@ async def login_openai_headless(config: Config) -> AsyncIterator[OAuthEvent]:
688
697
  OPENAI_DEVICE_REDIRECT_URI,
689
698
  )
690
699
  except Exception as exc:
700
+ from pythinker_code.telemetry.errors import report_handled_error
701
+
702
+ report_handled_error(exc, site="auth.openai.device_poll")
691
703
  yield OAuthEvent("error", f"OpenAI device login failed: {exc}")
692
704
  return
693
705
 
@@ -248,6 +248,9 @@ async def refresh_managed_models(config: Config) -> bool:
248
248
  try:
249
249
  await oauth_manager.ensure_fresh()
250
250
  except Exception as exc:
251
+ from pythinker_code.telemetry.errors import report_handled_error
252
+
253
+ report_handled_error(exc, site="auth.platforms.refresh.pre_sync")
251
254
  logger.warning(
252
255
  "Failed to refresh OAuth token before model sync for {platform}: {error}",
253
256
  platform=platform_id,
@@ -281,6 +284,9 @@ async def refresh_managed_models(config: Config) -> bool:
281
284
  try:
282
285
  await oauth_manager.ensure_fresh(force=True)
283
286
  except Exception as exc2:
287
+ from pythinker_code.telemetry.errors import report_handled_error
288
+
289
+ report_handled_error(exc2, site="auth.platforms.refresh.after_401")
284
290
  refresh_exc = exc2
285
291
  logger.warning(
286
292
  "Failed to refresh OAuth token after 401 for {platform}: {error}",
@@ -323,6 +329,9 @@ async def refresh_managed_models(config: Config) -> bool:
323
329
  changed = True
324
330
  continue
325
331
  except Exception as exc:
332
+ from pythinker_code.telemetry.errors import report_handled_error
333
+
334
+ report_handled_error(exc, site="auth.platforms.sync")
326
335
  fallback_models = _fallback_or_log(platform_id=platform_id, error=exc)
327
336
  if fallback_models is None:
328
337
  continue
@@ -237,7 +237,10 @@ class HookEngine:
237
237
  results = await self._execute_hooks(
238
238
  event, matcher_value, server_matched, wire_matched, input_data
239
239
  )
240
- except Exception:
240
+ except Exception as exc:
241
+ from pythinker_code.telemetry.errors import report_handled_error
242
+
243
+ report_handled_error(exc, site="hooks.engine.run")
241
244
  logger.warning("Hook engine error for {}, failing open", event)
242
245
  return []
243
246
 
@@ -278,6 +281,9 @@ class HookEngine:
278
281
  try:
279
282
  self._on_triggered(event, matcher_value, total)
280
283
  except Exception as e:
284
+ from pythinker_code.telemetry.errors import report_handled_error
285
+
286
+ report_handled_error(e, site="hooks.engine.triggered_cb")
281
287
  logger.warning(
282
288
  "HookTriggered callback failed for {event}: {error}, continuing",
283
289
  event=event,
@@ -328,6 +334,9 @@ class HookEngine:
328
334
  try:
329
335
  self._on_resolved(event, matcher_value, action, reason, duration_ms)
330
336
  except Exception as e:
337
+ from pythinker_code.telemetry.errors import report_handled_error
338
+
339
+ report_handled_error(e, site="hooks.engine.resolved_cb")
331
340
  logger.warning(
332
341
  "HookResolved callback failed for {event}: {error}, continuing",
333
342
  event=event,
@@ -366,6 +375,9 @@ class HookEngine:
366
375
  logger.warning("Wire hook timed out: {} {}", event, target)
367
376
  return HookResult(action="allow", timed_out=True)
368
377
  except Exception as e:
378
+ from pythinker_code.telemetry.errors import report_handled_error
379
+
380
+ report_handled_error(e, site="hooks.engine.wire")
369
381
  hook_task.cancel()
370
382
  logger.warning("Wire hook failed: {} {}: {}", event, target, e)
371
383
  return HookResult(action="allow")
@@ -51,6 +51,9 @@ async def run_hook(
51
51
  await proc.wait()
52
52
  raise
53
53
  except Exception as e:
54
+ from pythinker_code.telemetry.errors import report_handled_error
55
+
56
+ report_handled_error(e, site="hooks.runner")
54
57
  logger.warning("Hook failed: {}: {}", command, e)
55
58
  return HookResult(action="allow", stderr=str(e))
56
59
 
@@ -176,6 +176,9 @@ async def execute_side_question(
176
176
 
177
177
  return None, "No response received."
178
178
  except Exception as e:
179
+ from pythinker_code.telemetry.errors import report_handled_error
180
+
181
+ report_handled_error(e, site="soul.btw.execute")
179
182
  logger.warning("Side question failed: {error}", error=e)
180
183
  return None, str(e)
181
184
 
@@ -210,5 +213,8 @@ async def run_side_question(soul: PythinkerSoul, question: str) -> None:
210
213
  else:
211
214
  wire_send(BtwEnd(id=btw_id, error=error or "No response received."))
212
215
  except Exception as e:
216
+ from pythinker_code.telemetry.errors import report_handled_error
217
+
218
+ report_handled_error(e, site="soul.btw.run_wire")
213
219
  logger.warning("Side question failed: {error}", error=e)
214
220
  wire_send(BtwEnd(id=btw_id, error=str(e)))
@@ -286,7 +286,14 @@ class PythinkerSoul:
286
286
  try:
287
287
  result = await provider.get_injections(self._context.history, self)
288
288
  injections.extend(result)
289
- except Exception:
289
+ except Exception as exc:
290
+ from pythinker_code.telemetry.errors import report_handled_error
291
+
292
+ report_handled_error(
293
+ exc,
294
+ site="soul.injection.get",
295
+ provider=type(provider).__name__,
296
+ )
290
297
  logger.warning(
291
298
  "injection provider %s failed",
292
299
  type(provider).__name__,
@@ -304,7 +311,14 @@ class PythinkerSoul:
304
311
  for provider in self._injection_providers:
305
312
  try:
306
313
  await provider.on_context_compacted()
307
- except Exception:
314
+ except Exception as exc:
315
+ from pythinker_code.telemetry.errors import report_handled_error
316
+
317
+ report_handled_error(
318
+ exc,
319
+ site="soul.injection.on_context_compacted",
320
+ provider=type(provider).__name__,
321
+ )
308
322
  logger.warning(
309
323
  "injection provider %s on_context_compacted failed",
310
324
  type(provider).__name__,
@@ -316,7 +330,14 @@ class PythinkerSoul:
316
330
  for provider in self._injection_providers:
317
331
  try:
318
332
  await provider.on_auto_changed(enabled)
319
- except Exception:
333
+ except Exception as exc:
334
+ from pythinker_code.telemetry.errors import report_handled_error
335
+
336
+ report_handled_error(
337
+ exc,
338
+ site="soul.injection.on_auto_changed",
339
+ provider=type(provider).__name__,
340
+ )
320
341
  logger.warning(
321
342
  "injection provider %s on_auto_changed failed",
322
343
  type(provider).__name__,
@@ -937,6 +958,9 @@ class PythinkerSoul:
937
958
  try:
938
959
  await self.compact_context()
939
960
  except Exception as compact_err:
961
+ from pythinker_code.telemetry.errors import report_handled_error
962
+
963
+ report_handled_error(compact_err, site="soul.context.compact")
940
964
  logger.error(
941
965
  "Context compaction failed at step {step_no}: {error_type}: {error}",
942
966
  step_no=step_no,
@@ -952,6 +976,9 @@ class PythinkerSoul:
952
976
  except BackToTheFuture as e:
953
977
  back_to_the_future = e
954
978
  except Exception as e:
979
+ from pythinker_code.telemetry.errors import report_handled_error
980
+
981
+ report_handled_error(e, site="soul.step.error")
955
982
  # any other exception should interrupt the step
956
983
  req_id = getattr(e, "request_id", None)
957
984
  logger.error(
@@ -1453,7 +1480,10 @@ class PythinkerSoul:
1453
1480
  raise
1454
1481
  try:
1455
1482
  recovered = chat_provider.on_retryable_error(error)
1456
- except Exception:
1483
+ except Exception as recover_exc:
1484
+ from pythinker_code.telemetry.errors import report_handled_error
1485
+
1486
+ report_handled_error(recover_exc, site="soul.chat.recover")
1457
1487
  logger.exception(
1458
1488
  "Failed to recover chat provider during {name} after {error_type}.",
1459
1489
  name=name,
@@ -319,6 +319,9 @@ class PythinkerToolset:
319
319
  parameters=parameters,
320
320
  )
321
321
  except Exception as e:
322
+ from pythinker_code.telemetry.errors import report_handled_error
323
+
324
+ report_handled_error(e, site="soul.toolset.register_external")
322
325
  return False, str(e)
323
326
  self.add(tool)
324
327
  return True, None
@@ -490,6 +493,9 @@ class PythinkerToolset:
490
493
  logger.info("Connected MCP server: {server_name}", server_name=server_name)
491
494
  return server_name, None
492
495
  except Exception as e:
496
+ from pythinker_code.telemetry.errors import report_handled_error
497
+
498
+ report_handled_error(e, site="soul.toolset.mcp.connect")
493
499
  logger.error(
494
500
  "Failed to connect MCP server: {server_name}, error: {error}",
495
501
  server_name=server_name,
@@ -608,6 +614,7 @@ class MCPTool[T: ClientTransport](CallableTool):
608
614
  **kwargs,
609
615
  )
610
616
  self._mcp_tool = mcp_tool
617
+ self._mcp_server_name = server_name
611
618
  self._client = client
612
619
  self._runtime = runtime
613
620
  self._timeout = timedelta(milliseconds=runtime.config.mcp.client.tool_call_timeout_ms)
@@ -619,25 +626,43 @@ class MCPTool[T: ClientTransport](CallableTool):
619
626
  if not result:
620
627
  return result.rejection_error()
621
628
 
629
+ from pythinker_code.telemetry import otel as _otel
630
+
631
+ # `start_span` returns a sync context manager (the OTel SDK uses
632
+ # `_AgnosticContextManager`, which intentionally has no __aenter__).
633
+ # Keep it as a sync `with` and use `async with` only on the fastmcp
634
+ # client.
622
635
  try:
623
- async with self._client as client:
624
- result = await client.call_tool(
625
- self._mcp_tool.name,
626
- kwargs,
627
- timeout=self._timeout,
628
- raise_on_error=False,
629
- )
630
- if result.is_error:
631
- logger.warning(
632
- "MCP tool returned error: {tool_name}: {content}",
633
- tool_name=self._mcp_tool.name,
634
- content=[str(p) for p in result.content][:3],
636
+ with _otel.start_span(
637
+ "pythinker.mcp.call",
638
+ {
639
+ "mcp.server": self._mcp_server_name,
640
+ "mcp.tool": self._mcp_tool.name,
641
+ "mcp.timeout_ms": int(self._timeout.total_seconds() * 1000),
642
+ },
643
+ ) as span:
644
+ async with self._client as client:
645
+ result = await client.call_tool(
646
+ self._mcp_tool.name,
647
+ kwargs,
648
+ timeout=self._timeout,
649
+ raise_on_error=False,
635
650
  )
636
- return convert_mcp_tool_result(result)
651
+ span.set_attribute("mcp.is_error", bool(result.is_error))
652
+ if result.is_error:
653
+ logger.warning(
654
+ "MCP tool returned error: {tool_name}: {content}",
655
+ tool_name=self._mcp_tool.name,
656
+ content=[str(p) for p in result.content][:3],
657
+ )
658
+ return convert_mcp_tool_result(result)
637
659
  except Exception as e:
660
+ from pythinker_code.telemetry.errors import report_handled_error
661
+
638
662
  # fastmcp raises `RuntimeError` on timeout and we cannot tell it from other errors
639
663
  exc_msg = str(e).lower()
640
664
  if "timeout" in exc_msg or "timed out" in exc_msg:
665
+ report_handled_error(e, site="soul.toolset.mcp.call.timeout", tool="MCP")
641
666
  logger.warning(
642
667
  "MCP tool call timed out: {tool_name}: {error}",
643
668
  tool_name=self._mcp_tool.name,
@@ -650,6 +675,7 @@ class MCPTool[T: ClientTransport](CallableTool):
650
675
  ),
651
676
  brief="Timeout",
652
677
  )
678
+ report_handled_error(e, site="soul.toolset.mcp.call", tool="MCP")
653
679
  logger.error(
654
680
  "MCP tool call failed: {tool_name}: {error}",
655
681
  tool_name=self._mcp_tool.name,
@@ -693,6 +719,9 @@ class WireExternalTool(CallableTool):
693
719
  except asyncio.CancelledError:
694
720
  raise
695
721
  except Exception as e:
722
+ from pythinker_code.telemetry.errors import report_handled_error
723
+
724
+ report_handled_error(e, site="soul.toolset.external_tool", tool="External")
696
725
  logger.exception("External tool call failed: {tool_name}:", tool_name=self.name)
697
726
  return ToolError(
698
727
  message=f"External tool call failed: {e}",
@@ -124,6 +124,9 @@ async def run_soul_checked(
124
124
  brief="LLM provider error",
125
125
  )
126
126
  except Exception as exc:
127
+ from pythinker_code.telemetry.errors import report_handled_error
128
+
129
+ report_handled_error(exc, site="subagents.run.soul")
127
130
  logger.exception("Subagent soul run failed when {phase}", phase=phase)
128
131
  return SoulRunFailure(
129
132
  message=f"Unexpected error when {phase}: {exc}",
@@ -316,7 +319,10 @@ class ForegroundSubagentRunner:
316
319
  self._store.update_instance(agent_id, status="killed")
317
320
  output_writer.stage("cancelled")
318
321
  raise RunCancelled("Subagent run was cancelled.") from exc
319
- except Exception:
322
+ except Exception as exc:
323
+ from pythinker_code.telemetry.errors import report_handled_error
324
+
325
+ report_handled_error(exc, site="subagents.run.background")
320
326
  self._store.update_instance(agent_id, status="failed")
321
327
  output_writer.stage("failed_exception")
322
328
  raise
@@ -52,3 +52,32 @@ def is_disabled() -> bool:
52
52
  Sentry and OTel emission for the process."""
53
53
  raw = os.environ.get("PYTHINKER_DISABLE_TELEMETRY", "").strip().lower()
54
54
  return raw in {"1", "true", "yes", "on"}
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Sampling
59
+ # ---------------------------------------------------------------------------
60
+
61
+ DEFAULT_OTEL_TRACE_SAMPLE_RATE = 1.0
62
+ """Default fraction of root-trace spans to record. 1.0 = always-on; 0.0 = none."""
63
+
64
+
65
+ def otel_trace_sample_rate() -> float:
66
+ """Resolve the OTel trace sampling rate.
67
+
68
+ Honors ``PYTHINKER_OTEL_TRACE_SAMPLE_RATE``. Clamped to ``[0.0, 1.0]``.
69
+ Malformed input falls back to the default rather than disabling tracing
70
+ or raising — telemetry config must never break the host program.
71
+ """
72
+ raw = os.environ.get("PYTHINKER_OTEL_TRACE_SAMPLE_RATE", "").strip()
73
+ if not raw:
74
+ return DEFAULT_OTEL_TRACE_SAMPLE_RATE
75
+ try:
76
+ rate = float(raw)
77
+ except ValueError:
78
+ return DEFAULT_OTEL_TRACE_SAMPLE_RATE
79
+ if rate < 0.0:
80
+ return 0.0
81
+ if rate > 1.0:
82
+ return 1.0
83
+ return rate
@@ -0,0 +1,107 @@
1
+ """Helper for reporting handled exceptions to Sentry/Bugsink + OTel.
2
+
3
+ Use this at any except-block site that intentionally turns an exception into
4
+ a graceful user-facing error result (a ``ToolError``, a TUI message, a logged
5
+ warning). The site keeps its existing failure-rendering behaviour; the helper
6
+ additionally informs the monitoring stack so dashboards can see the failure
7
+ rate, class, and bucket.
8
+
9
+ Privacy posture
10
+ ---------------
11
+ Only pass primitive enum-like attributes through ``**attrs`` (tool name,
12
+ class names, mode flags). The OTel ``error`` event is forwarded verbatim, so
13
+ **never** pass user input, file paths, or code snippets there. Sentry is fine
14
+ with full exception data — its ``before_send`` hook already scrubs paths and
15
+ strips PII before transmission.
16
+
17
+ Both forwarding paths are wrapped in :func:`contextlib.suppress` because
18
+ telemetry must never break the host program.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import time
25
+ from collections import deque
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ from pythinker_code.telemetry import sentry as _sentry
30
+ from pythinker_code.telemetry import track
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Process-local ring buffer of recent errors
34
+ # ---------------------------------------------------------------------------
35
+ # Keeps just enough metadata to populate the ``/report-error`` slash command
36
+ # without retaining the full exception object (which can hold large frames
37
+ # and references). Class name + a short, redacted message is what shows in
38
+ # the buffer; the *full* scrubbed stack is already in Sentry/Bugsink.
39
+
40
+ _RECENT_BUFFER_SIZE = 10
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class RecentError:
45
+ timestamp: float
46
+ site: str
47
+ exc_class: str
48
+ message: str # truncated to 200 chars
49
+ tool: str | None
50
+
51
+
52
+ _recent: deque[RecentError] = deque(maxlen=_RECENT_BUFFER_SIZE)
53
+
54
+
55
+ def recent_errors() -> list[RecentError]:
56
+ """Snapshot of the most-recent reported errors (oldest first)."""
57
+ return list(_recent)
58
+
59
+
60
+ def clear_recent_errors() -> None:
61
+ """Drop the recent-errors buffer. Used by tests and by ``/report-error``
62
+ after a successful submission."""
63
+ _recent.clear()
64
+
65
+
66
+ def report_handled_error(
67
+ exc: BaseException,
68
+ *,
69
+ site: str,
70
+ tool: str | None = None,
71
+ **attrs: bool | int | float | str | None,
72
+ ) -> None:
73
+ """Forward a caught-and-rendered exception to Sentry + the OTel error stream.
74
+
75
+ Args:
76
+ exc: The exception that was caught at the call site.
77
+ site: Stable identifier for the catch site, e.g. ``"tool.read"`` or
78
+ ``"auth.oauth.refresh"``. Used to bucket failures in dashboards.
79
+ Must be a stable enum-like string, not a free-form message.
80
+ tool: Optional tool name when the site is a tool implementation
81
+ (e.g. ``"ReadFile"``). Forwarded as a separate property so SigNoz
82
+ queries can group by tool without parsing ``site``.
83
+ **attrs: Additional primitive enum-like attributes. Booleans, numbers
84
+ and short strings only. Values must not contain user input,
85
+ absolute paths, or code snippets.
86
+ """
87
+ properties: dict[str, Any] = {
88
+ "site": site,
89
+ "exc_class": type(exc).__name__,
90
+ }
91
+ if tool is not None:
92
+ properties["tool"] = tool
93
+ properties.update(attrs)
94
+ with contextlib.suppress(Exception):
95
+ track("error", **properties)
96
+ with contextlib.suppress(Exception):
97
+ _sentry.capture_exception(exc)
98
+ with contextlib.suppress(Exception):
99
+ _recent.append(
100
+ RecentError(
101
+ timestamp=time.time(),
102
+ site=site,
103
+ exc_class=type(exc).__name__,
104
+ message=str(exc)[:200],
105
+ tool=tool,
106
+ )
107
+ )
@@ -36,9 +36,21 @@ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
36
36
  from opentelemetry.sdk.resources import Resource
37
37
  from opentelemetry.sdk.trace import TracerProvider
38
38
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
39
+ from opentelemetry.sdk.trace.sampling import (
40
+ ALWAYS_OFF,
41
+ ALWAYS_ON,
42
+ ParentBased,
43
+ Sampler,
44
+ TraceIdRatioBased,
45
+ )
39
46
  from opentelemetry.trace import Tracer
40
47
 
41
- from pythinker_code.telemetry.config import is_disabled, otel_endpoint, otel_ingest_token
48
+ from pythinker_code.telemetry.config import (
49
+ is_disabled,
50
+ otel_endpoint,
51
+ otel_ingest_token,
52
+ otel_trace_sample_rate,
53
+ )
42
54
 
43
55
  _TRACER_NAME = "pythinker-code"
44
56
  _initialized: bool = False
@@ -97,7 +109,17 @@ def init(
97
109
  headers=headers,
98
110
  timeout=10,
99
111
  )
100
- tracer_provider = TracerProvider(resource=resource)
112
+ rate = otel_trace_sample_rate()
113
+ sampler: Sampler
114
+ if rate >= 1.0:
115
+ sampler = ALWAYS_ON
116
+ elif rate <= 0.0:
117
+ sampler = ALWAYS_OFF
118
+ else:
119
+ # ParentBased honors a parent's sampling decision (e.g. an upstream
120
+ # ACP/wire request). For root spans the ratio sampler decides.
121
+ sampler = ParentBased(root=TraceIdRatioBased(rate))
122
+ tracer_provider = TracerProvider(resource=resource, sampler=sampler)
101
123
  tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
102
124
  trace.set_tracer_provider(tracer_provider)
103
125
  _tracer = tracer_provider.get_tracer(_TRACER_NAME, version)
@@ -157,6 +157,9 @@ class AgentTool(CallableTool2[Params]):
157
157
  logger.exception("Foreground agent run failed")
158
158
  return ToolError(message=f"Failed to run agent: {exc}", brief="Agent failed")
159
159
  except Exception as exc:
160
+ from pythinker_code.telemetry.errors import report_handled_error
161
+
162
+ report_handled_error(exc, site="tool.agent.foreground", tool="Agent")
160
163
  logger.exception("Foreground agent run failed")
161
164
  return ToolError(message=f"Failed to run agent: {exc}", brief="Agent failed")
162
165