polyrob 0.5.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 (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. webview/webgate.py +196 -0
@@ -0,0 +1,969 @@
1
+ """OpenRouter LLM API Client.
2
+
3
+ OpenRouter provides unified access to multiple AI models through a single API.
4
+ API is OpenAI-compatible, supporting Grok, Kimi, Qwen, and many others.
5
+
6
+ Docs: https://openrouter.ai/docs/quickstart
7
+
8
+ Refactored Dec 2025: All model configuration comes from model_registry.
9
+ No deprecated fallback constants - registry is the single source of truth.
10
+ """
11
+
12
+ import logging
13
+ import re
14
+ import time
15
+ import json
16
+ from typing import List, Dict, Any, Optional, Union, Tuple
17
+ from uuid import uuid4
18
+ from openai import AsyncOpenAI
19
+
20
+ from modules.llm.llm_client import LLMClient, translate_llm_error
21
+ from modules.llm.token_counter import count_messages_tokens
22
+ from modules.llm.model_registry import get_model_config, ModelProvider
23
+ from core.exceptions import LLMError, ServiceError, LLMRateLimitError, LLMAuthenticationError
24
+ from core.config import BotConfig
25
+
26
+ # Import default model from registry (mirrors anthropic_client.py's pattern; no
27
+ # circular import — llm_client_registry only imports openrouter_client lazily,
28
+ # inside function bodies, never at module level).
29
+ from modules.llm.llm_client_registry import get_default_model
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Kimi-K2 emits tool calls with special delimiter tokens. When a serving layer
34
+ # (notably NVIDIA NIM) doesn't parse them, they leak into ``message.content`` as
35
+ # raw text with no structured ``tool_calls`` — costing the agent the actions and
36
+ # dumping the tokens to output. Recover them client-side.
37
+ # <|tool_call_begin|> functions.{name}:{idx} <|tool_call_argument_begin|> {json} <|tool_call_end|>
38
+ #
39
+ # We locate each call's *header* (name + idx + the argument-begin marker) with a
40
+ # narrow regex, then decode the JSON args with ``json.JSONDecoder().raw_decode``
41
+ # from the first ``{`` so a complete JSON object is consumed regardless of any
42
+ # trailing tokens. The old ``(?P<args>\{.*?\})`` group truncated when a string
43
+ # value literally contained ``} <|tool_call_end|>`` (Bug 3).
44
+ _KIMI_TOOL_CALL_HEADER_RE = re.compile(
45
+ r"<\|tool_call_begin\|>\s*functions\.(?P<name>[\w.\-]+):(?P<idx>\d+)\s*"
46
+ r"<\|tool_call_argument_begin\|>\s*",
47
+ re.DOTALL,
48
+ )
49
+ #: Any Kimi tool-call control token — marks where the parseable content ends.
50
+ _KIMI_TOKEN_MARKER = "<|tool_call"
51
+
52
+
53
+ def _new_recovered_call_id(tag: str, i: int) -> str:
54
+ """Mint a GLOBALLY unique id for a recovered tool call (P0-4).
55
+
56
+ Recovered ids used to be deterministic per-response (``call_{i}_{idx}`` /
57
+ ``call_txt_{i}``), so two recovery turns in one session produced DUPLICATE
58
+ ids across history. Downstream, ``tool_message_repair.repair_tool_message_pairs``
59
+ keys its ``tool_msg_map`` by id over the WHOLE history (last write wins) and
60
+ ``del``s the id on first use — so step 1's AIMessage got paired with step 2's
61
+ tool result and step 2 got a fabricated "[ERROR: No response recorded...]"
62
+ placeholder. Textual leaks fire on ~20-25% of Kimi/NIM turns, making the
63
+ collision routine.
64
+
65
+ Mirrors the ``tool_call_builder.normalize_tool_call`` reference pattern
66
+ (uuid4 for missing ids), kept short/provider-safe: ``call_{tag}_{hex8}_{i}``.
67
+ The trailing enumeration index ``i`` keeps ids distinct within one response
68
+ even in the astronomically-unlikely event of a hex collision, and preserves
69
+ call ordering for debuggability.
70
+ """
71
+ return f"call_{tag}_{uuid4().hex[:8]}_{i}"
72
+
73
+
74
+ def parse_kimi_tool_calls(content: str) -> List[Dict[str, Any]]:
75
+ """Extract OpenAI-shape tool calls from Kimi's leaked delimiter tokens.
76
+
77
+ Returns ``[]`` when *content* carries no (valid) Kimi tool-call tokens, so it
78
+ is a safe no-op for every other model/provider. Args that don't parse as JSON
79
+ are skipped (with a distinct WARN) rather than guessed.
80
+
81
+ Recovered ids are **globally unique** via ``_new_recovered_call_id`` (uuid4
82
+ suffix). This subsumes the earlier per-response uniqueness fix (Bug 1: Kimi's
83
+ ``idx`` restarts at 0 per function name, so two parallel calls could collide
84
+ within one response) AND the cross-response collision (P0-4: deterministic
85
+ ids repeated across recovery turns, corrupting ``tool_message_repair``'s
86
+ id-keyed history map). The enumeration index ``i`` is retained for ordering
87
+ and intra-response distinctness.
88
+ """
89
+ text = content or ""
90
+ out: List[Dict[str, Any]] = []
91
+ decoder = json.JSONDecoder()
92
+ for i, m in enumerate(_KIMI_TOOL_CALL_HEADER_RE.finditer(text)):
93
+ name = m.group("name")
94
+ idx = m.group("idx")
95
+ # Args start at the first '{' after the argument-begin marker. raw_decode
96
+ # consumes exactly one complete JSON object and ignores trailing tokens,
97
+ # so an embedded ``} <|tool_call_end|>`` inside a string value no longer
98
+ # truncates the capture.
99
+ brace = text.find("{", m.end())
100
+ if brace == -1:
101
+ logger.warning(
102
+ f"event=kimi_toolcall_parse_error model=? name={name} idx={idx} "
103
+ f"reason=no_opening_brace (Kimi tool-call header without JSON args — dropped)"
104
+ )
105
+ continue
106
+ try:
107
+ obj, end = decoder.raw_decode(text, brace)
108
+ except (ValueError, TypeError) as e:
109
+ logger.warning(
110
+ f"event=kimi_toolcall_parse_error name={name} idx={idx} "
111
+ f"reason=json_decode_failed err={e} (Kimi tool-call args did not parse — dropped)"
112
+ )
113
+ continue
114
+ out.append({
115
+ "id": _new_recovered_call_id("kimi", i),
116
+ "type": "function",
117
+ "function": {"name": name, "arguments": json.dumps(obj)},
118
+ })
119
+ return out
120
+
121
+
122
+ def strip_kimi_tokens(content: str) -> str:
123
+ """Drop everything from the first Kimi control token onward.
124
+
125
+ The brain-state text precedes the token soup, so this keeps it for brain
126
+ extraction while removing the unparsed tool-call tokens from what streams.
127
+ """
128
+ if not content:
129
+ return content
130
+ cut = content.find(_KIMI_TOKEN_MARKER)
131
+ return content[:cut].rstrip() if cut != -1 else content
132
+
133
+
134
+ def recover_kimi_content(content: str) -> Tuple[str, List[Dict[str, Any]]]:
135
+ """Reconcile leaked Kimi control tokens in *content* into clean output.
136
+
137
+ Returns ``(cleaned_content, recovered_tool_calls)``. When no Kimi control
138
+ token is present this is a no-op: ``(content, [])``.
139
+
140
+ The key invariant (WS-2.1): **stripping is unconditional once any token
141
+ leaks**, independent of whether structured calls were recovered. Three leak
142
+ shapes were seen from kimi-k2.6 on NVIDIA NIM:
143
+ 1. pure brain JSON (no tokens) → no marker, untouched
144
+ 2. brain JSON + full ``<|tool_call_begin|>`` blocks → calls recovered + stripped
145
+ 3. brain JSON + stray ``<|tool_call_end|>`` only → 0 calls but STILL stripped
146
+ The old code only stripped in case (2), so case (3) leaked raw tokens to the
147
+ user. This always strips, so the tokens become structurally impossible to leak.
148
+ """
149
+ if not content or _KIMI_TOKEN_MARKER not in content:
150
+ return (content, [])
151
+ return (strip_kimi_tokens(content), parse_kimi_tool_calls(content))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Textual tool-call recovery (B4, 2026-06-16). kimi-k2.6 on NVIDIA NIM ALSO
156
+ # leaks tool calls in two NON-pipe-token shapes when the serving layer fails to
157
+ # emit structured ``tool_calls`` — both captured live:
158
+ # A. Anthropic XML: <invoke name="done"><parameter name="text">hi</parameter></invoke>
159
+ # B. python-call: done(text="hi") (recovered only for KNOWN tool names)
160
+ # The pipe-token path (recover_kimi_content) never sees these. Left unrecovered,
161
+ # the action is LOST (empty-action loop) AND the raw text dumps to the user as a
162
+ # "rob" bubble (the leak shapes end in ``)``/``>`` so the render-layer brain-state
163
+ # check doesn't demote them). recover_textual_tool_calls reconciles both shapes.
164
+ # ---------------------------------------------------------------------------
165
+
166
+ _XML_INVOKE_RE = re.compile(
167
+ r"<invoke\b[^>]*\bname=\"(?P<name>[\w.\-]+)\"[^>]*>(?P<body>.*?)</invoke>",
168
+ re.DOTALL,
169
+ )
170
+ _XML_PARAM_RE = re.compile(
171
+ r"<parameter\b[^>]*\bname=\"(?P<key>[\w.\-]+)\"[^>]*>(?P<val>.*?)</parameter>",
172
+ re.DOTALL,
173
+ )
174
+ #: Stray/partial function-call XML tags to strip even when no full block parses
175
+ #: (mirrors the WS-2.1 "always strip" invariant for pipe tokens — a lone
176
+ #: ``</invoke>`` must never reach the user).
177
+ _XML_TOOLCALL_TAG_RE = re.compile(
178
+ r"</?(?:function_calls|invoke|parameter)\b[^>]*>", re.DOTALL
179
+ )
180
+ #: GLM/zhipu leak: a whole ``<function_calls> ... </function_calls>`` dump (tool name
181
+ #: as tag + ``<arg_key>``/``<arg_value>`` children, frequently malformed). The args
182
+ #: can't be reliably recovered from the malformed shape, but the block must be
183
+ #: stripped wholesale so raw tool-call XML never reaches the user. Non-greedy to the
184
+ #: first close tag, or to end-of-string when GLM omits/mangles the close.
185
+ _FUNCTION_CALLS_BLOCK_RE = re.compile(
186
+ r"<function_calls\b.*?(?:</function_calls>|$)", re.DOTALL
187
+ )
188
+
189
+
190
+ def _has_toolcall_xml_marker(content: Optional[str]) -> bool:
191
+ """True if content carries a tool-call XML marker (provider-agnostic leak signal).
192
+
193
+ Prose virtually never contains these literal markers, so their presence is a
194
+ reliable signal to run textual-tool-call recovery/stripping for ANY provider —
195
+ not just Kimi (GLM leaks ``<function_calls>``; Anthropic-style leaks ``<invoke>``).
196
+ """
197
+ if not content:
198
+ return False
199
+ return "<function_calls" in content or "<invoke" in content
200
+
201
+
202
+ def tool_names_from_schemas(tools: Optional[List[Dict[str, Any]]]) -> set:
203
+ """Extract the set of callable tool names from OpenAI-format tool schemas."""
204
+ names: set = set()
205
+ for t in tools or []:
206
+ if not isinstance(t, dict):
207
+ continue
208
+ fn = t.get("function") if isinstance(t.get("function"), dict) else None
209
+ name = (fn or {}).get("name") or t.get("name")
210
+ if isinstance(name, str) and name:
211
+ names.add(name)
212
+ return names
213
+
214
+
215
+ def _coerce_param_value(raw: str) -> Any:
216
+ """Best-effort coerce an XML ``<parameter>`` body to a JSON value, else str."""
217
+ s = (raw or "").strip()
218
+ try:
219
+ return json.loads(s)
220
+ except (ValueError, TypeError):
221
+ return raw
222
+
223
+
224
+ def _parse_call_args(inner: str) -> Optional[Dict[str, Any]]:
225
+ """Parse the argument list of a textual ``name(...)`` call.
226
+
227
+ Accepts ``{json}`` (a single JSON object) or ``key=<json-value>`` kwargs
228
+ (the common ``text="..."`` shape). Returns ``None`` when nothing parses, so a
229
+ bare prose ``foo(bar)`` mention isn't misread as a tool call.
230
+ """
231
+ s = (inner or "").strip()
232
+ if not s:
233
+ return {}
234
+ decoder = json.JSONDecoder()
235
+ if s.startswith("{"):
236
+ try:
237
+ obj, _ = decoder.raw_decode(s)
238
+ if isinstance(obj, dict):
239
+ return obj
240
+ except (ValueError, TypeError):
241
+ return None
242
+ return None
243
+ args: Dict[str, Any] = {}
244
+ kw_re = re.compile(r"\s*(?P<key>[\w.\-]+)\s*=\s*")
245
+ i, n = 0, len(s)
246
+ while i < n:
247
+ m = kw_re.match(s, i)
248
+ if not m:
249
+ break
250
+ key = m.group("key")
251
+ j = m.end()
252
+ if j < n and s[j] in '"[{':
253
+ try:
254
+ val, end = decoder.raw_decode(s, j)
255
+ except (ValueError, TypeError):
256
+ break
257
+ args[key] = val
258
+ i = end
259
+ else:
260
+ k = s.find(",", j)
261
+ if k == -1:
262
+ k = n
263
+ token = s[j:k].strip()
264
+ try:
265
+ args[key] = json.loads(token)
266
+ except (ValueError, TypeError):
267
+ args[key] = token
268
+ i = k
269
+ while i < n and s[i] in ", \n\t":
270
+ i += 1
271
+ return args or None
272
+
273
+
274
+ def _find_balanced_paren(text: str, open_idx: int) -> int:
275
+ """Index of the ``)`` matching the ``(`` at *open_idx*, respecting double-
276
+ quoted strings so a ``)`` inside a string value doesn't close early.
277
+ Returns -1 when unbalanced."""
278
+ depth = 0
279
+ in_str = False
280
+ esc = False
281
+ for i in range(open_idx, len(text)):
282
+ c = text[i]
283
+ if in_str:
284
+ if esc:
285
+ esc = False
286
+ elif c == "\\":
287
+ esc = True
288
+ elif c == '"':
289
+ in_str = False
290
+ continue
291
+ if c == '"':
292
+ in_str = True
293
+ elif c == "(":
294
+ depth += 1
295
+ elif c == ")":
296
+ depth -= 1
297
+ if depth == 0:
298
+ return i
299
+ return -1
300
+
301
+
302
+ def recover_textual_tool_calls(
303
+ content: str, known_names: Optional[set] = None
304
+ ) -> Tuple[str, List[Dict[str, Any]]]:
305
+ """Recover XML / python-call tool calls leaked as raw text; strip them out.
306
+
307
+ Returns ``(cleaned_content, tool_calls)``; a no-op returns ``(content, [])``.
308
+ Only ``known_names`` are recovered from the ``name(...)`` shape so prose that
309
+ merely mentions a tool isn't misread as a call. The ``name(...)`` scan starts
310
+ AFTER any leading brain-state JSON object, so a tool name quoted inside the
311
+ brain JSON can't trigger a spurious call (or corrupt the JSON on strip).
312
+ """
313
+ if not content:
314
+ return (content, [])
315
+ found: List[Tuple[int, int, str, Dict[str, Any]]] = []
316
+
317
+ # A. Anthropic <invoke name="...">...</invoke> blocks (whole content).
318
+ for m in _XML_INVOKE_RE.finditer(content):
319
+ body = m.group("body")
320
+ args: Dict[str, Any] = {}
321
+ for pm in _XML_PARAM_RE.finditer(body):
322
+ args[pm.group("key")] = _coerce_param_value(pm.group("val"))
323
+ if not args:
324
+ parsed = _parse_call_args(body)
325
+ if parsed:
326
+ args = parsed
327
+ found.append((m.start(), m.end(), m.group("name"), args))
328
+
329
+ # B. python-call name(...) for KNOWN names — only when no XML block matched,
330
+ # and only over the tail AFTER a leading brain-state JSON object.
331
+ if not found and known_names:
332
+ scan_start = 0
333
+ lead = len(content) - len(content.lstrip())
334
+ tail = content[lead:]
335
+ if tail.startswith("{"):
336
+ try:
337
+ _obj, end = json.JSONDecoder().raw_decode(tail)
338
+ scan_start = lead + end
339
+ except (ValueError, TypeError):
340
+ scan_start = 0
341
+ region = content[scan_start:]
342
+ for name in known_names:
343
+ for m in re.finditer(rf"(?<![\w.]){re.escape(name)}\s*\(", region):
344
+ open_idx = region.index("(", m.start())
345
+ close = _find_balanced_paren(region, open_idx)
346
+ if close == -1:
347
+ continue
348
+ args = _parse_call_args(region[open_idx + 1 : close])
349
+ if args is None:
350
+ continue
351
+ found.append(
352
+ (scan_start + m.start(), scan_start + close + 1, name, args)
353
+ )
354
+
355
+ found.sort(key=lambda f: f[0])
356
+ calls = [
357
+ {
358
+ "id": _new_recovered_call_id("txt", i),
359
+ "type": "function",
360
+ "function": {"name": name, "arguments": json.dumps(args)},
361
+ }
362
+ for i, (_s, _e, name, args) in enumerate(found)
363
+ ]
364
+
365
+ cleaned = content
366
+ for start, end, _n, _a in sorted(found, key=lambda f: f[0], reverse=True):
367
+ cleaned = cleaned[:start] + cleaned[end:]
368
+ # GLM/zhipu: strip any whole <function_calls> dump block (incl. its malformed
369
+ # inner tool-name / arg_key / arg_value tags) so raw XML never reaches the user.
370
+ cleaned = _FUNCTION_CALLS_BLOCK_RE.sub("", cleaned)
371
+ # Backstop: strip any stray/partial function-call XML tags left behind.
372
+ cleaned = _XML_TOOLCALL_TAG_RE.sub("", cleaned).strip()
373
+ return (cleaned, calls)
374
+
375
+
376
+ class OpenRouterClient(LLMClient):
377
+ """OpenRouter LLM client - unified access to Grok, Kimi, Qwen, and more.
378
+
379
+ OpenRouter uses OpenAI-compatible API format, so this client extends
380
+ the patterns from OpenAIClient while adding OpenRouter-specific headers.
381
+
382
+ All model capabilities and limits come from model_registry.
383
+ """
384
+
385
+ # OpenRouter API base URL (fallback; the declarative source is ProviderProfile — P8)
386
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
387
+
388
+ # Human-readable provider name used in log/exception messages. Subclasses that
389
+ # reuse this OpenAI-compatible pipeline (e.g. NvidiaClient) override it so their
390
+ # logs/errors name the real provider instead of "OpenRouter".
391
+ _PROVIDER_LABEL = "OpenRouter"
392
+
393
+ def _profile_base_url(self) -> str:
394
+ """Transport base URL sourced from the declarative ProviderProfile (P8),
395
+ falling back to the hardcoded constant if the profile is unavailable.
396
+
397
+ Delegates to the base ``_resolve_profile_base_url`` helper (profiles.py
398
+ is the single source of truth); keeps the class-level constant as the
399
+ fallback so no prior call sites need to change.
400
+ """
401
+ return self._resolve_profile_base_url("openrouter") or self.OPENROUTER_BASE_URL
402
+
403
+ def __init__(self, config: BotConfig, name: str = "openrouter_client"):
404
+ """Initialize the OpenRouter client."""
405
+ super().__init__(config=config, name=name)
406
+ self._client = None
407
+
408
+ # Get OpenRouter config
409
+ openrouter_config = config.get_llm_config().get('openrouter', {})
410
+ self.api_key = openrouter_config.get('api_key')
411
+ # Default to the registry's SSOT default (P0.7: was a stale hardcoded
412
+ # literal that had drifted from DEFAULT_MODELS['openrouter']). Honors the
413
+ # POLYROB_OPENROUTER_MODEL env override via get_default_model().
414
+ self.model_type = openrouter_config.get('model') or get_default_model('openrouter')
415
+
416
+ # OpenRouter-specific headers for app attribution
417
+ self.site_url = openrouter_config.get('site_url', '')
418
+ self.site_name = openrouter_config.get('site_name', 'POLYROB AI Agent')
419
+
420
+ self.last_response = None
421
+
422
+ # Get model config from registry (single source of truth)
423
+ model_config = get_model_config(self.model_type)
424
+ if model_config:
425
+ self.max_tokens = model_config.max_completion_tokens
426
+ else:
427
+ # Fallback defaults for OpenRouter (Grok defaults)
428
+ self.max_tokens = 32768
429
+ self.logger.warning(f"Model '{self.model_type}' not found in registry, using defaults")
430
+
431
+ # Resolve vision support via base helper (falls back to True if not in registry)
432
+ self.supports_vision = self._resolve_supports_vision()
433
+
434
+ self.temperature = 0.7 # OpenRouter default
435
+
436
+ self.logger.debug(
437
+ f"OpenRouter client initialized: model={self.model_type}, "
438
+ f"max_tokens={self.max_tokens}, supports_vision={self.supports_vision}"
439
+ )
440
+
441
+ def _supports_tools(self) -> bool:
442
+ """Check if current model supports tool calling via model_registry."""
443
+ config = get_model_config(self.model_type)
444
+ if config and config.capabilities:
445
+ return config.capabilities.supports_function_calling
446
+ return True # Most OpenRouter models support tools
447
+
448
+ def _validate_llm_config(self) -> None:
449
+ """Validate LLM config."""
450
+ if not self.api_key:
451
+ raise ServiceError(f"{self._PROVIDER_LABEL} API key not provided")
452
+
453
+ async def _setup_client(self) -> None:
454
+ """Set up the OpenRouter client using OpenAI SDK."""
455
+ try:
456
+ self._client = AsyncOpenAI(
457
+ api_key=self.api_key,
458
+ base_url=self._profile_base_url(), # P8: profile-sourced transport URL
459
+ default_headers=self._get_openrouter_headers()
460
+ )
461
+ self.logger.debug(f"{self._PROVIDER_LABEL} client setup completed")
462
+ except Exception as e:
463
+ raise ServiceError(f"Failed to set up {self._PROVIDER_LABEL} client: {e}")
464
+
465
+ def _get_openrouter_headers(self) -> Dict[str, str]:
466
+ """Get OpenRouter-specific headers for app attribution."""
467
+ headers = {}
468
+ if self.site_url:
469
+ headers["HTTP-Referer"] = self.site_url
470
+ if self.site_name:
471
+ headers["X-Title"] = self.site_name
472
+ return headers
473
+
474
+ async def _validate_connection(self) -> None:
475
+ """Validate OpenRouter connection."""
476
+ try:
477
+ response = await self._client.chat.completions.create(
478
+ model=self.model_type,
479
+ messages=[{"role": "user", "content": "Test"}],
480
+ max_tokens=5
481
+ )
482
+ if not response or not response.choices:
483
+ raise LLMError(f"No valid response from {self._PROVIDER_LABEL} API")
484
+ self.logger.debug(f"{self._PROVIDER_LABEL} API connection validated successfully")
485
+ except Exception as e:
486
+ self.logger.error(f"Failed to validate {self._PROVIDER_LABEL} connection: {e}")
487
+ raise ServiceError(f"Failed to validate {self._PROVIDER_LABEL} connection: {e}")
488
+
489
+ async def _cleanup_client(self) -> None:
490
+ """Clean up OpenRouter client."""
491
+ if self._client:
492
+ if hasattr(self._client, 'close'):
493
+ await self._client.close()
494
+ self._client = None
495
+ self.logger.debug(f"{self._PROVIDER_LABEL} client resources released")
496
+
497
+ async def _initialize(self) -> None:
498
+ """Initialize the client."""
499
+ if self._initialized:
500
+ return
501
+
502
+ try:
503
+ # Log initialization attempt
504
+ self.logger.info(f"Initializing {self._PROVIDER_LABEL} client with model {self.model_type}")
505
+
506
+ # Validate API key
507
+ self._validate_llm_config()
508
+
509
+ # Setup client
510
+ await self._setup_client()
511
+
512
+ # Test connection (skipped for fast startup; validates lazily)
513
+ if not self._skip_validate:
514
+ await self._validate_connection()
515
+
516
+ # Mark as initialized
517
+ self._initialized = True
518
+
519
+ # Log successful initialization
520
+ self.logger.info(f"✨ {self._PROVIDER_LABEL} client initialized successfully with model {self.model_type}")
521
+
522
+ except Exception as e:
523
+ # Single error log with clear cause
524
+ error_msg = f"Failed to initialize {self._PROVIDER_LABEL} client: {str(e)}"
525
+ self.logger.error(error_msg)
526
+ raise ServiceError(error_msg)
527
+
528
+ async def _generate(
529
+ self,
530
+ messages: List[Dict[str, str]],
531
+ temperature: Optional[float] = None,
532
+ max_tokens: Optional[int] = None,
533
+ **kwargs
534
+ ) -> str:
535
+ """Generate a response from OpenRouter API."""
536
+ start_time = time.time()
537
+
538
+ try:
539
+ if not self._initialized:
540
+ await self._initialize()
541
+
542
+ # Format messages
543
+ openrouter_messages = []
544
+ for msg in messages:
545
+ role = msg.get('role', 'user')
546
+ if role == 'ai':
547
+ role = 'assistant'
548
+
549
+ message_dict = {"role": role, "content": msg.get('content', '')}
550
+
551
+ # Handle tool messages
552
+ if role == 'tool' and 'tool_call_id' in msg:
553
+ message_dict["tool_call_id"] = msg["tool_call_id"]
554
+ if role == 'assistant' and 'tool_calls' in msg:
555
+ message_dict["tool_calls"] = msg["tool_calls"]
556
+
557
+ openrouter_messages.append(message_dict)
558
+
559
+ temp = temperature if temperature is not None else self.temperature
560
+ max_tokens_value = self._adjust_max_tokens(messages, max_tokens)
561
+
562
+ # Make request
563
+ request_params = {
564
+ 'model': self.model_type,
565
+ 'messages': openrouter_messages,
566
+ 'max_tokens': max_tokens_value,
567
+ 'temperature': temp
568
+ }
569
+
570
+ self.logger.debug(f"{self._PROVIDER_LABEL} API request: model={self.model_type}, max_tokens={max_tokens_value}")
571
+
572
+ self.last_response = await self._client.chat.completions.create(**request_params)
573
+
574
+ # Extract response
575
+ response_text = ""
576
+ if self.last_response.choices:
577
+ choice = self.last_response.choices[0]
578
+ if choice.message:
579
+ response_text = choice.message.content or ""
580
+
581
+ self._extract_usage_and_capture_telemetry(start_time, True, None, kwargs.get('metadata'))
582
+ return response_text
583
+
584
+ except Exception as e:
585
+ self._extract_usage_and_capture_telemetry(start_time, False, str(e), kwargs.get('metadata'))
586
+ self.logger.error(f"{self._PROVIDER_LABEL} API error: {e}")
587
+
588
+ # Route through the unified classifier; fall back to ServiceError for generic
589
+ # errors (preserving the original observable contract at this call site).
590
+ translated = translate_llm_error(e, self._PROVIDER_LABEL)
591
+ if type(translated) is LLMError:
592
+ raise ServiceError(f"{self._PROVIDER_LABEL} API error: {e}")
593
+ raise translated
594
+
595
+ async def generate_response(
596
+ self,
597
+ prompt: Optional[Union[str, Dict[str, Any]]] = None,
598
+ messages: Optional[List[Dict[str, str]]] = None,
599
+ system: Optional[str] = None,
600
+ temperature: Optional[float] = None,
601
+ max_tokens: Optional[int] = None,
602
+ metadata: Optional[Dict[str, Any]] = None,
603
+ **kwargs
604
+ ) -> str:
605
+ """Generate a response from the LLM."""
606
+ try:
607
+ # Handle prompt formats
608
+ if messages is not None:
609
+ formatted_messages = messages
610
+ elif isinstance(prompt, list) and all(isinstance(m, dict) for m in prompt):
611
+ formatted_messages = prompt
612
+ elif isinstance(prompt, dict) and 'messages' in prompt:
613
+ formatted_messages = prompt['messages']
614
+ elif isinstance(prompt, str):
615
+ formatted_messages = [{"role": "user", "content": prompt}]
616
+ else:
617
+ formatted_messages = [{"role": "user", "content": "Hello"}]
618
+
619
+ # Add system message
620
+ if system:
621
+ has_system = any(msg.get('role') == 'system' for msg in formatted_messages)
622
+ if not has_system:
623
+ formatted_messages.insert(0, {"role": "system", "content": system})
624
+
625
+ return await self._generate(
626
+ messages=formatted_messages,
627
+ temperature=temperature,
628
+ max_tokens=max_tokens,
629
+ metadata=metadata,
630
+ **kwargs
631
+ )
632
+
633
+ except Exception as e:
634
+ self.logger.error(f"Failed to generate response: {e}")
635
+ raise ServiceError(f"Failed to generate response: {e}")
636
+
637
+ async def _generate_with_tools(
638
+ self,
639
+ messages: List[Dict[str, Any]],
640
+ tools: Optional[List[Dict[str, Any]]] = None,
641
+ system: Optional[str] = None,
642
+ temperature: Optional[float] = None,
643
+ max_tokens: Optional[int] = None,
644
+ metadata: Optional[Dict[str, Any]] = None,
645
+ **kwargs
646
+ ) -> Tuple[str, List[Dict[str, Any]], Dict[str, Optional[int]]]:
647
+ """Generate response with tool calling support.
648
+
649
+ Args:
650
+ messages: List of messages
651
+ tools: List of tools in OpenAI format
652
+ system: System prompt
653
+ temperature: Temperature for generation
654
+ max_tokens: Maximum tokens for generation
655
+ metadata: Request metadata
656
+ **kwargs: Additional parameters
657
+
658
+ Returns:
659
+ Tuple of (text_response, tool_calls, usage_data)
660
+ """
661
+ start_time = time.time()
662
+
663
+ try:
664
+ if not self._initialized:
665
+ await self._initialize()
666
+
667
+ # Format messages
668
+ formatted_messages = []
669
+ if system:
670
+ formatted_messages.append({"role": "system", "content": system})
671
+
672
+ for msg in messages:
673
+ formatted_msg = msg.copy()
674
+ role = msg.get('role', 'user')
675
+ if role == 'ai':
676
+ formatted_msg['role'] = 'assistant'
677
+
678
+ # Convert tool calls to OpenAI format if needed
679
+ if msg.get("role") == "assistant" and "tool_calls" in msg:
680
+ tool_calls = []
681
+ for tc in msg.get("tool_calls", []):
682
+ if not isinstance(tc.get("function"), dict):
683
+ if "name" in tc and "args" in tc:
684
+ tool_calls.append({
685
+ "id": tc["id"],
686
+ "type": "function",
687
+ "function": {
688
+ "name": tc["name"],
689
+ "arguments": json.dumps(tc["args"]) if not isinstance(tc.get("args"), str) else tc["args"]
690
+ }
691
+ })
692
+ else:
693
+ tool_calls.append(tc)
694
+ else:
695
+ tool_calls.append(tc)
696
+ formatted_msg["tool_calls"] = tool_calls
697
+ formatted_messages.append(formatted_msg)
698
+
699
+ temp = temperature if temperature is not None else self.temperature
700
+ max_tokens_value = self._adjust_max_tokens(messages, max_tokens)
701
+
702
+ # P1-3: mark the stable system prefix cacheable for breakpoint-style models
703
+ # (Anthropic/Gemini via OpenRouter). No-op unless OPENROUTER_PROMPT_CACHE is on.
704
+ from modules.llm.cache_hints import apply_openrouter_cache_control
705
+ formatted_messages = apply_openrouter_cache_control(formatted_messages, self.model_type)
706
+
707
+ request_params = {
708
+ 'model': self.model_type,
709
+ 'messages': formatted_messages,
710
+ 'max_tokens': max_tokens_value,
711
+ 'temperature': temp
712
+ }
713
+
714
+ if tools:
715
+ # Ensure tools is a list (not dict) in OpenAI format
716
+ if isinstance(tools, dict):
717
+ self.logger.warning(f"Tools passed as dict, expected list. This may indicate a schema generator issue.")
718
+ # Try to extract list if it's wrapped in a dict
719
+ if 'function_declarations' in tools:
720
+ # Gemini format - convert to OpenAI format
721
+ tools = [{"type": "function", "function": f} for f in tools['function_declarations']]
722
+ else:
723
+ # Unknown dict format - skip tools
724
+ self.logger.error(f"Cannot convert tools dict to list: {list(tools.keys())}")
725
+ tools = None
726
+
727
+ if tools:
728
+ # UP-08: extend the cache breakpoint over the tools block for
729
+ # breakpoint-style models (no-op unless OPENROUTER_PROMPT_CACHE on).
730
+ from modules.llm.cache_hints import apply_openrouter_tools_cache_control
731
+ tools = apply_openrouter_tools_cache_control(tools, self.model_type)
732
+ request_params['tools'] = tools
733
+ request_params['tool_choice'] = 'auto'
734
+
735
+ self.logger.info(f"[_generate_with_tools] Starting API request: model={self.model_type}, tools={len(tools) if tools else 0}, messages={len(formatted_messages)}")
736
+
737
+ api_start = time.time()
738
+ response = await self._client.chat.completions.create(**request_params)
739
+ api_duration = time.time() - api_start
740
+
741
+ self.last_response = response
742
+ self.logger.info(f"[_generate_with_tools] API request completed in {api_duration:.1f}s")
743
+
744
+ message = response.choices[0].message
745
+ usage_data = self._extract_usage_data()
746
+
747
+ if hasattr(message, 'tool_calls') and message.tool_calls:
748
+ content = message.content or ""
749
+ tool_calls_list = []
750
+ for tc in message.tool_calls:
751
+ # Log raw arguments for debugging MCP nested args issue
752
+ raw_args = tc.function.arguments
753
+ self.logger.info(f"[RAW_TOOL_CALL] {tc.function.name}: arguments type={type(raw_args).__name__}, value={raw_args[:500] if isinstance(raw_args, str) else raw_args}")
754
+
755
+ tool_calls_list.append({
756
+ "id": tc.id,
757
+ "type": tc.type,
758
+ "function": {
759
+ "name": tc.function.name,
760
+ "arguments": raw_args
761
+ }
762
+ })
763
+ self.logger.debug(f"Extracted {len(tool_calls_list)} tool calls from response")
764
+ return (content, tool_calls_list, usage_data)
765
+
766
+ # Fallback: serving layers (notably NVIDIA NIM with Kimi-K2)
767
+ # intermittently fail to parse Kimi's native tool-call tokens and
768
+ # return them as raw text in content with no structured tool_calls.
769
+ # Recover any calls AND always strip the tokens (WS-2.1) so the agent
770
+ # executes the actions (instead of wasting a planning turn) and the
771
+ # control tokens never leak — even in the stray-end-token shape that
772
+ # recovers 0 calls.
773
+ raw_content = message.content or ""
774
+ # L3 FIX: only run Kimi token recovery for Kimi models. The marker
775
+ # ("<|tool_call") is only ever leaked by Kimi-K2 on NIM; gating on the
776
+ # model means a non-Kimi OpenRouter model (Grok/Qwen/…) that legitimately
777
+ # quotes that literal in prose isn't silently truncated.
778
+ cleaned_content, recovered = raw_content, []
779
+ if "kimi" in (self.model_type or "").lower():
780
+ cleaned_content, recovered = recover_kimi_content(raw_content)
781
+ # B4 + GLM leak: also recover/strip tool calls leaked as <invoke> XML,
782
+ # GLM <function_calls> dumps, or done(...) python-call syntax. The pipe
783
+ # pass (Kimi-only above) handles <|tool_call| tokens; this handles XML.
784
+ # Marker-gated (provider-agnostic): a <function_calls>/<invoke> marker in
785
+ # content is a reliable leak signal that prose ~never contains — so GLM
786
+ # (z-ai) leaks are now recovered/stripped too, not just Kimi. No marker =>
787
+ # untouched (byte-identical for the common path).
788
+ if not recovered and (
789
+ _has_toolcall_xml_marker(cleaned_content)
790
+ or "kimi" in (self.model_type or "").lower()
791
+ ):
792
+ cleaned_content, textual = recover_textual_tool_calls(
793
+ cleaned_content, tool_names_from_schemas(tools)
794
+ )
795
+ recovered = textual
796
+ if cleaned_content != raw_content or recovered:
797
+ # R4: emit a structured, greppable breadcrumb so a Kimi recovery is
798
+ # observable in telemetry/logs at PARSE time (the structured-provider
799
+ # path has no equivalent silent step). Stable marker: kimi_toolcall_recovery.
800
+ if recovered:
801
+ self.logger.warning(
802
+ f"event=kimi_toolcall_recovery model={self.model_type} "
803
+ f"recovered_calls={len(recovered)} action=recovered "
804
+ f"({self._PROVIDER_LABEL}: recovered Kimi tool call(s) from unparsed "
805
+ f"content tokens — serving-layer parser miss)"
806
+ )
807
+ else:
808
+ self.logger.warning(
809
+ f"event=kimi_toolcall_recovery model={self.model_type} "
810
+ f"recovered_calls=0 action=stripped_only "
811
+ f"({self._PROVIDER_LABEL}: stripped leaked Kimi control token(s) with no "
812
+ f"recoverable tool calls — serving-layer parser miss)"
813
+ )
814
+ return (cleaned_content, recovered, usage_data)
815
+
816
+ except Exception as e:
817
+ self._extract_usage_and_capture_telemetry(start_time, False, str(e), metadata)
818
+ self.logger.error(f"{self._PROVIDER_LABEL} tool generation failed: {e}")
819
+ raise LLMError(f"{self._PROVIDER_LABEL} generation failed: {e}")
820
+
821
+ async def generate_agent_response(
822
+ self,
823
+ messages: List[Dict[str, Any]],
824
+ tools: List[Dict[str, Any]],
825
+ **kwargs
826
+ ) -> Union[Tuple[str, List[Dict[str, Any]], Dict[str, Optional[int]]], str]:
827
+ """Generate response with tool-calling support for agents.
828
+
829
+ This method bridges the adapter contract to enable native tool-calling
830
+ for OpenRouter models. It delegates to _generate_with_tools which handles the
831
+ wire-format conversion and API interaction.
832
+
833
+ Args:
834
+ messages: List of message dictionaries
835
+ tools: List of tool schemas in OpenAI-compatible format
836
+ **kwargs: Additional generation parameters
837
+ - system: System prompt (CRITICAL for brain state instructions)
838
+ - temperature: Temperature setting
839
+ - max_tokens: Max completion tokens
840
+ - metadata: Request metadata
841
+
842
+ Returns:
843
+ Either:
844
+ - Tuple of (content, tool_calls, usage_data) when tool calls are present
845
+ usage_data dict contains: prompt_tokens, completion_tokens, total_tokens
846
+ - Just the content string when no tool calls
847
+
848
+ Raises:
849
+ LLMError: If generation fails
850
+ NotImplementedError: If model doesn't support tool calling
851
+ """
852
+ try:
853
+ self.logger.info(f"[generate_agent_response] Called with {len(messages)} messages and {len(tools) if tools else 0} tools")
854
+
855
+ # Extract system message if present in kwargs
856
+ system = kwargs.pop('system', None)
857
+ temperature = kwargs.pop('temperature', None)
858
+ max_tokens = kwargs.pop('max_tokens', None)
859
+
860
+ # Check if this model supports tool calling (uses model_registry as source of truth)
861
+ if not self._supports_tools():
862
+ self.logger.warning(
863
+ f"OpenRouter model {self.model_type} does not support native tool calling. "
864
+ "Raising NotImplementedError to trigger fallback."
865
+ )
866
+ raise NotImplementedError(
867
+ f"OpenRouter model {self.model_type} does not support native tool/function calling"
868
+ )
869
+
870
+ # Call the tool-enabled generation method
871
+ content, tool_calls, usage_data = await self._generate_with_tools(
872
+ messages=messages,
873
+ tools=tools,
874
+ system=system,
875
+ temperature=temperature,
876
+ max_tokens=max_tokens,
877
+ **kwargs
878
+ )
879
+
880
+ self.logger.info(f"[generate_agent_response] Returning {len(tool_calls)} tool calls + usage")
881
+
882
+ # Always return the 3-tuple (Bug 2). Tool-free conversational/planning
883
+ # turns (and every 0-recovered-call Kimi turn) previously returned a
884
+ # bare content string, dropping message-level usage/billing telemetry.
885
+ # tool_calls may be an empty list; OpenAI/Gemini clients already do this.
886
+ return (content, tool_calls, usage_data)
887
+
888
+ except NotImplementedError:
889
+ # Re-raise NotImplementedError to trigger fallback
890
+ raise
891
+ except Exception as e:
892
+ self.logger.error(f"Error in generate_agent_response: {e}", exc_info=True)
893
+ raise LLMError(f"Failed to generate agent response: {e}")
894
+
895
+ async def validate(self) -> bool:
896
+ """Validate the OpenRouter client by making a test request."""
897
+ try:
898
+ if not self._client:
899
+ await self._setup_client()
900
+
901
+ response = await self._client.chat.completions.create(
902
+ model=self.model_type,
903
+ messages=[{"role": "user", "content": "test"}],
904
+ max_tokens=1
905
+ )
906
+ if not response or not response.choices:
907
+ raise ValueError(f"Invalid response from {self._PROVIDER_LABEL}")
908
+ self.logger.info(f"{self._PROVIDER_LABEL} client validated with model {self.model_type}")
909
+ return True
910
+ except Exception as e:
911
+ self.logger.error(f"{self._PROVIDER_LABEL} validation failed: {e}")
912
+ # Use similar error translation as DeepSeek
913
+ error_str = str(e).lower()
914
+ if "rate" in error_str or "429" in error_str:
915
+ raise ValueError(f"{self._PROVIDER_LABEL} API rate limit exceeded. Please try again later.")
916
+ elif "key" in error_str or "auth" in error_str or "401" in error_str:
917
+ raise ValueError(f"Invalid {self._PROVIDER_LABEL} API key.")
918
+ else:
919
+ raise ValueError(f"{self._PROVIDER_LABEL} validation failed: {str(e)}")
920
+
921
+ async def _make_validation_request(self) -> Any:
922
+ """Make a validation request to OpenRouter API.
923
+
924
+ Required abstract method implementation for LLMClient base class.
925
+ """
926
+ return await self._client.chat.completions.create(
927
+ model=self.model_type,
928
+ messages=[{"role": "user", "content": "test"}],
929
+ max_tokens=1
930
+ )
931
+
932
+ def _check_validation_response(self, response: Any) -> None:
933
+ """Check validation response from OpenRouter API.
934
+
935
+ Required abstract method implementation for LLMClient base class.
936
+
937
+ Args:
938
+ response: Response from _make_validation_request
939
+
940
+ Raises:
941
+ LLMError: If response is invalid
942
+ """
943
+ if not response or not response.choices:
944
+ raise LLMError(f"Invalid response from {self._PROVIDER_LABEL} API - no choices returned")
945
+
946
+ def _extract_usage_data(self) -> Dict[str, Optional[int]]:
947
+ """Extract usage data from response."""
948
+ if not self.last_response or not hasattr(self.last_response, 'usage'):
949
+ return {'prompt_tokens': None, 'completion_tokens': None, 'total_tokens': None}
950
+
951
+ usage = self.last_response.usage
952
+ # UP-08: surface server-side prefix-cache hits (mirrors OpenAIClient). Benefits
953
+ # NIM too (NvidiaClient subclasses this). cached_tokens feeds cached_input_price.
954
+ cached_tokens = None
955
+ details = getattr(usage, 'prompt_tokens_details', None)
956
+ if details is not None:
957
+ cached_tokens = getattr(details, 'cached_tokens', None)
958
+ return {
959
+ 'prompt_tokens': getattr(usage, 'prompt_tokens', None),
960
+ 'completion_tokens': getattr(usage, 'completion_tokens', None),
961
+ 'total_tokens': getattr(usage, 'total_tokens', None),
962
+ 'cached_tokens': cached_tokens or 0,
963
+ }
964
+
965
+ async def cleanup(self) -> None:
966
+ """Clean up resources."""
967
+ await self._cleanup_client()
968
+ self._initialized = False
969
+ self.logger.info(f"{self._PROVIDER_LABEL} client cleaned up")