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,1367 @@
1
+ """Google Gemini LLM API Client."""
2
+
3
+ import logging
4
+ import asyncio
5
+ import time
6
+ from typing import List, Dict, Any, Optional, Union, Tuple
7
+ import json
8
+ import os
9
+ import warnings
10
+ from uuid import uuid4
11
+ # google.generativeai emits a noisy package-level FutureWarning ("all support has
12
+ # ended") at import. We're aware (migration tracked separately); silence it so it
13
+ # doesn't corrupt the CLI's first screen.
14
+ with warnings.catch_warnings():
15
+ warnings.simplefilter("ignore", FutureWarning)
16
+ import google.generativeai as genai
17
+ import google.ai.generativelanguage as glm
18
+
19
+ from modules.llm.llm_client import LLMClient, translate_llm_error
20
+ from modules.llm.token_counter import count_messages_tokens
21
+ from core.exceptions import LLMError, LLMConnectionError, LLMRateLimitError, ServiceError
22
+ from core.config import BotConfig
23
+
24
+ class GeminiClient(LLMClient):
25
+ """Google Gemini LLM client.
26
+
27
+ All model configuration comes from model_registry (single source of truth).
28
+ Deprecated fallback dicts removed Dec 13, 2025.
29
+ """
30
+
31
+ def __init__(self, config: BotConfig, name: str = "gemini_client", container=None):
32
+ """Initialize the client."""
33
+ super().__init__(config=config, name=name, container=container)
34
+ self._client = None
35
+
36
+ # Model and API configuration
37
+ llm_config = config.get_llm_config()
38
+ gemini_config = llm_config.get('gemini', {})
39
+
40
+ # Default to gemini-2.5-flash (latest stable as of Nov 2025)
41
+ self.model_type = gemini_config.get('model', 'gemini-2.5-flash')
42
+
43
+ # API key priority: 1) gemini config, 2) GEMINI_API_KEY env, 3) GOOGLE_API_KEY env
44
+ self.api_key = (
45
+ gemini_config.get('api_key') or
46
+ os.environ.get('GEMINI_API_KEY') or
47
+ os.environ.get('GOOGLE_API_KEY')
48
+ )
49
+
50
+ # If we found a key, also set environment variable for libraries that use it directly
51
+ if self.api_key:
52
+ os.environ['GEMINI_API_KEY'] = self.api_key
53
+ os.environ['GOOGLE_API_KEY'] = self.api_key
54
+
55
+ self.last_response = None # Store the last response for token usage reporting
56
+
57
+ # UP-08: explicit cachedContents (opt-in, GEMINI_PROMPT_CACHE). A billed, TTL'd
58
+ # server object reused across steps; busted on tool-set change; deleted on cleanup.
59
+ self._cached_content = None
60
+ self._cached_tool_sig = None
61
+
62
+ # Get defaults from model_registry (single source of truth)
63
+ from modules.llm.model_registry import get_model_config
64
+ model_config = get_model_config(self.model_type)
65
+
66
+ # Get model limits from registry
67
+ if model_config:
68
+ self.max_tokens = model_config.max_completion_tokens or 8192
69
+ else:
70
+ self.max_tokens = 8192 # Conservative fallback
71
+
72
+ # Resolve vision support via base helper (falls back to True if not in registry)
73
+ self.supports_vision = self._resolve_supports_vision()
74
+
75
+ self.temperature = 0.7 # Default temperature
76
+
77
+ self.logger.debug(
78
+ f"Gemini client initialized: model={self.model_type}, "
79
+ f"supports_vision={self.supports_vision}"
80
+ )
81
+
82
+ def _is_gemini_3_model(self) -> bool:
83
+ """Check if current model is Gemini 3 (requires thought signatures for function calling).
84
+
85
+ Gemini 3 models enforce strict thought signature validation during function calling.
86
+ When thought signatures are missing, the API may return malformed FunctionCall objects
87
+ with empty name fields, causing tool calls to fail.
88
+
89
+ Returns:
90
+ True if model is Gemini 3 series, False otherwise
91
+ """
92
+ if not self.model_type:
93
+ return False
94
+ model_lower = self.model_type.lower()
95
+ # Match gemini-3-*, gemini3*, etc.
96
+ return 'gemini-3' in model_lower or 'gemini3' in model_lower
97
+
98
+ # ---- UP-08 explicit cachedContents -------------------------------------
99
+ @staticmethod
100
+ def _tool_signature(tool_objects) -> str:
101
+ """Stable signature of the tool schema so we can bust a stale cache."""
102
+ names = []
103
+ for t in (tool_objects or []):
104
+ for f in getattr(t, 'function_declarations', []) or []:
105
+ names.append(getattr(f, 'name', ''))
106
+ return ",".join(sorted(names))
107
+
108
+ @staticmethod
109
+ def _cache_signature(system_instruction, tool_objects) -> str:
110
+ """L1: reuse key for an explicit cachedContents object. The cached object bakes in
111
+ BOTH the system_instruction AND the tools, so the reuse key MUST include both —
112
+ keying on tool names alone let a session reuse another session's cached SYSTEM
113
+ prompt (persona/SOUL/SELF/project-context) whenever the tool set matched."""
114
+ import hashlib
115
+ sys_hash = hashlib.sha256((system_instruction or "").encode("utf-8")).hexdigest()[:16]
116
+ return f"{sys_hash}:{GeminiClient._tool_signature(tool_objects)}"
117
+
118
+ def _delete_cached_content(self) -> None:
119
+ """Delete the current cached object (fail-open) and clear local state."""
120
+ cache = self._cached_content
121
+ self._cached_content = None
122
+ self._cached_tool_sig = None
123
+ if cache is not None:
124
+ try:
125
+ cache.delete()
126
+ except Exception as e:
127
+ self.logger.debug(f"Gemini cache delete failed (ignored): {e}")
128
+
129
+ def _maybe_build_cached_content(self, system_instruction, tool_objects):
130
+ """Return a reusable CachedContent for the stable system+tools prefix, or None.
131
+
132
+ UP-08, default OFF (GEMINI_PROMPT_CACHE). Scoped to the non-Gemini-3 tools path
133
+ (Gemini-3 ChatSession + thought-signatures interact badly with cached content).
134
+ Reused across steps; busted when the tool-set signature changes; fail-open to the
135
+ normal uncached path on any SDK error. Requires an estimated prefix >= the API
136
+ floor (~2048 tokens) to be worth a billed cache object.
137
+ """
138
+ from modules.llm.cache_hints import (
139
+ gemini_explicit_cache_enabled, GEMINI_EXPLICIT_CACHE_MIN_TOKENS)
140
+ if not gemini_explicit_cache_enabled():
141
+ return None
142
+ if self._is_gemini_3_model():
143
+ return None
144
+ # Rough prefix-size estimate (~4 chars/token): system text + tool schema names.
145
+ est_chars = len(system_instruction or "") + len(self._tool_signature(tool_objects))
146
+ if est_chars // 4 < GEMINI_EXPLICIT_CACHE_MIN_TOKENS:
147
+ return None
148
+ # L1: reuse key includes the system prompt, not just the tool set — a cached
149
+ # object bakes in both, so busting only on tool change leaked the system prompt.
150
+ sig = self._cache_signature(system_instruction, tool_objects)
151
+ # Reuse within session unless the system prompt OR tool set changed.
152
+ if self._cached_content is not None and self._cached_tool_sig == sig:
153
+ return self._cached_content
154
+ if self._cached_content is not None:
155
+ self._delete_cached_content() # stale prefix -> bust before recreate
156
+ try:
157
+ import google.generativeai as genai
158
+ import datetime
159
+ ttl_min = int(os.getenv("GEMINI_CACHE_TTL_MIN", "10"))
160
+ cache = genai.caching.CachedContent.create(
161
+ model=self.model_type,
162
+ system_instruction=system_instruction or None,
163
+ tools=tool_objects or None,
164
+ ttl=datetime.timedelta(minutes=ttl_min),
165
+ )
166
+ self._cached_content = cache
167
+ self._cached_tool_sig = sig
168
+ self.logger.info(
169
+ f"event=gemini_explicit_cache_created model={self.model_type} "
170
+ f"tools={sig.count(',') + 1 if sig else 0} ttl_min={ttl_min}"
171
+ )
172
+ return cache
173
+ except Exception as e:
174
+ self.logger.warning(f"Gemini explicit cache create failed, continuing uncached: {e}")
175
+ self._cached_content = None
176
+ self._cached_tool_sig = None
177
+ return None
178
+
179
+ def _convert_content_to_parts(self, content: Any) -> List[Dict[str, Any]]:
180
+ """Convert message content to Gemini parts format.
181
+
182
+ Handles:
183
+ - String content (text)
184
+ - List of content parts (multimodal - text, images)
185
+ - OpenAI image_url format -> Gemini inline_data format
186
+
187
+ Args:
188
+ content: Message content (string or list of parts)
189
+
190
+ Returns:
191
+ List of Gemini part dictionaries
192
+ """
193
+ parts = []
194
+
195
+ # Handle string content
196
+ if isinstance(content, str):
197
+ if content.strip():
198
+ parts.append({"text": content})
199
+ return parts
200
+
201
+ # Handle list content (multimodal)
202
+ if isinstance(content, list):
203
+ for item in content:
204
+ if isinstance(item, str):
205
+ if item.strip():
206
+ parts.append({"text": item})
207
+ elif isinstance(item, dict):
208
+ item_type = item.get("type", "")
209
+
210
+ # Text part
211
+ if item_type == "text":
212
+ text = item.get("text", "")
213
+ if text.strip():
214
+ parts.append({"text": text})
215
+
216
+ # Image part - OpenAI format (image_url)
217
+ elif item_type == "image_url":
218
+ image_part = self._convert_image_url_to_gemini(item)
219
+ if image_part:
220
+ parts.append(image_part)
221
+
222
+ # Image part - Anthropic format (image with source)
223
+ elif item_type == "image":
224
+ image_part = self._convert_image_to_gemini(item)
225
+ if image_part:
226
+ parts.append(image_part)
227
+
228
+ # Already Gemini format (inline_data)
229
+ elif "inline_data" in item:
230
+ parts.append(item)
231
+
232
+ else:
233
+ self.logger.warning(f"Unknown content part type: {item_type}")
234
+
235
+ return parts
236
+
237
+ def _convert_image_url_to_gemini(self, image_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
238
+ """Convert OpenAI image_url format to Gemini inline_data format.
239
+
240
+ OpenAI format:
241
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
242
+
243
+ Gemini format:
244
+ {"inline_data": {"mime_type": "image/png", "data": "..."}}
245
+
246
+ Args:
247
+ image_item: Image in OpenAI format
248
+
249
+ Returns:
250
+ Image in Gemini format or None if conversion fails
251
+ """
252
+ try:
253
+ image_url_data = image_item.get("image_url", {})
254
+ url = image_url_data.get("url", "")
255
+
256
+ if not url:
257
+ self.logger.warning("Empty image URL in image_url format")
258
+ return None
259
+
260
+ # Handle base64 data URLs
261
+ if url.startswith("data:"):
262
+ try:
263
+ # Parse data URL: data:image/png;base64,<data>
264
+ header, base64_data = url.split(",", 1)
265
+
266
+ # Extract MIME type from header
267
+ mime_type = "image/png" # Default
268
+ if header.startswith("data:"):
269
+ header_content = header[5:] # Remove "data:"
270
+ if ";" in header_content:
271
+ mime_type = header_content.split(";")[0]
272
+ elif header_content:
273
+ mime_type = header_content
274
+
275
+ return {
276
+ "inline_data": {
277
+ "mime_type": mime_type,
278
+ "data": base64_data
279
+ }
280
+ }
281
+ except ValueError as e:
282
+ self.logger.error(f"Failed to parse base64 data URL: {e}")
283
+ return None
284
+
285
+ # Handle regular URLs - Gemini doesn't support URL images directly
286
+ # Would need to download and convert to base64
287
+ elif url.startswith("http://") or url.startswith("https://"):
288
+ self.logger.warning(f"Gemini doesn't support URL images directly. URL: {url[:50]}...")
289
+ return {"text": "[IMAGE: URL images not supported by Gemini]"}
290
+
291
+ else:
292
+ self.logger.warning(f"Unknown image URL format: {url[:50]}...")
293
+ return None
294
+
295
+ except Exception as e:
296
+ self.logger.error(f"Error converting image_url to Gemini format: {e}")
297
+ return None
298
+
299
+ def _convert_image_to_gemini(self, image_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
300
+ """Convert Anthropic image format to Gemini inline_data format.
301
+
302
+ Anthropic format:
303
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}
304
+
305
+ Gemini format:
306
+ {"inline_data": {"mime_type": "image/png", "data": "..."}}
307
+
308
+ Args:
309
+ image_item: Image in Anthropic format
310
+
311
+ Returns:
312
+ Image in Gemini format or None if conversion fails
313
+ """
314
+ try:
315
+ source = image_item.get("source", {})
316
+ source_type = source.get("type", "")
317
+
318
+ if source_type == "base64":
319
+ return {
320
+ "inline_data": {
321
+ "mime_type": source.get("media_type", "image/png"),
322
+ "data": source.get("data", "")
323
+ }
324
+ }
325
+ elif source_type == "url":
326
+ url = source.get("url", "")
327
+ self.logger.warning(f"Gemini doesn't support URL images directly. URL: {url[:50]}...")
328
+ return {"text": "[IMAGE: URL images not supported by Gemini]"}
329
+ else:
330
+ self.logger.warning(f"Unknown image source type: {source_type}")
331
+ return None
332
+
333
+ except Exception as e:
334
+ self.logger.error(f"Error converting image to Gemini format: {e}")
335
+ return None
336
+
337
+ def _validate_llm_config(self) -> None:
338
+ """Validate LLM config."""
339
+ if not self.api_key:
340
+ raise ServiceError("Gemini API key not provided. Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable or configure in config.json under llm.gemini.api_key")
341
+
342
+ async def _setup_client(self) -> None:
343
+ """Set up the Gemini client."""
344
+ try:
345
+ # Configure the Gemini API with the API key
346
+ genai.configure(api_key=self.api_key)
347
+ self.logger.debug("Gemini client setup completed with API key: ****...****")
348
+ except Exception as e:
349
+ self.logger.error(f"Failed to set up Gemini client: {e}")
350
+ raise ServiceError(f"Failed to set up Gemini client: {e}")
351
+
352
+ async def _initialize(self) -> None:
353
+ """Initialize the client."""
354
+ if self._initialized:
355
+ return
356
+ try:
357
+ self._validate_llm_config()
358
+ await self._setup_client()
359
+
360
+ if not self._skip_validate:
361
+ await self._validate_connection()
362
+
363
+ self._initialized = True
364
+ self.logger.info(f"✅ Gemini client initialized with model {self.model_type}")
365
+
366
+ except Exception as e:
367
+ self.logger.error(f"Failed to initialize Gemini client: {e}")
368
+ raise ServiceError(f"Failed to initialize Gemini client: {e}")
369
+
370
+ async def _generate(
371
+ self,
372
+ messages: List[Dict[str, str]],
373
+ system: Optional[str] = None,
374
+ temperature: Optional[float] = None,
375
+ max_tokens: Optional[int] = None,
376
+ **kwargs
377
+ ) -> str:
378
+ """Generate a response from Gemini API."""
379
+ start_time = time.time()
380
+ success = False
381
+ error_message = None
382
+
383
+ try:
384
+ # Ensure client is initialized
385
+ if not self._initialized:
386
+ await self._initialize()
387
+
388
+ # Convert messages to Gemini format
389
+ gemini_messages = []
390
+ has_system = False
391
+ # H1 FIX: capture the system prompt (from the `system` kwarg OR embedded as a
392
+ # role='system' message) and pass it natively via system_instruction below.
393
+ # Previously a system message in `messages` was skipped and never re-added
394
+ # (the injection block only fired for the `system` kwarg), so every no-tools
395
+ # Gemini call silently lost its system prompt.
396
+ system_instruction_text = system if isinstance(system, str) else None
397
+
398
+ for msg in messages:
399
+ role = msg.get('role', '')
400
+ content = msg.get('content', '')
401
+
402
+ if role == 'system':
403
+ has_system = True
404
+ # Prefer the content embedded in the message list — the agent builds
405
+ # the real system prompt there, not via the `system` kwarg.
406
+ if content and isinstance(content, str):
407
+ system_instruction_text = content
408
+ continue # passed natively via system_instruction below
409
+
410
+ # FIX (Dec 2, 2025): Handle tool role in non-tools path
411
+ # When _generate is called without tools but history has tool messages,
412
+ # convert them to text representations to avoid format errors
413
+ if role == 'tool':
414
+ tool_name = msg.get('name', '')
415
+ tool_call_id = msg.get('tool_call_id', '')
416
+ # Convert tool response to text format for non-tool generation
417
+ tool_text = f"[Tool Result ({tool_name or tool_call_id})]: {content}"
418
+ # Add as user message (tool responses are from user's perspective)
419
+ if gemini_messages and gemini_messages[-1]["role"] == "user":
420
+ gemini_messages[-1]["parts"].append({"text": tool_text})
421
+ else:
422
+ gemini_messages.append({
423
+ "role": "user",
424
+ "parts": [{"text": tool_text}]
425
+ })
426
+ continue
427
+
428
+ # Map roles to Gemini format
429
+ if role == 'user':
430
+ gemini_role = 'user'
431
+ elif role in ['assistant', 'ai']:
432
+ gemini_role = 'model'
433
+ # FIX (Dec 2, 2025): Handle assistant messages with tool_calls in non-tools path
434
+ # Convert tool calls to text representation
435
+ if 'tool_calls' in msg and msg['tool_calls']:
436
+ tool_calls_text = []
437
+ for tc in msg['tool_calls']:
438
+ if isinstance(tc, dict):
439
+ tc_name = tc.get('name') or (tc.get('function', {}).get('name') if 'function' in tc else 'unknown')
440
+ tc_args = tc.get('args') or tc.get('arguments') or (tc.get('function', {}).get('arguments') if 'function' in tc else {})
441
+ tool_calls_text.append(f"Called {tc_name} with {tc_args}")
442
+ if tool_calls_text:
443
+ combined = content + "\n[Tool Calls: " + "; ".join(tool_calls_text) + "]" if content else "[Tool Calls: " + "; ".join(tool_calls_text) + "]"
444
+ gemini_messages.append({
445
+ "role": gemini_role,
446
+ "parts": [{"text": combined}]
447
+ })
448
+ continue
449
+ else:
450
+ self.logger.warning(f"Unknown role {role}, skipping message")
451
+ continue
452
+
453
+ # FIX (Dec 3, 2025): Properly handle multimodal content (text, images)
454
+ # Content might be a string or list of parts (OpenAI multimodal format)
455
+ parts = self._convert_content_to_parts(content)
456
+ if parts:
457
+ gemini_messages.append({
458
+ "role": gemini_role,
459
+ "parts": parts
460
+ })
461
+
462
+ # System prompt is passed natively via system_instruction on the
463
+ # GenerativeModel below (see system_instruction_text capture above) —
464
+ # no need to inject a synthetic user/model turn pair anymore.
465
+
466
+ # Use provided parameters or default to instance attributes
467
+ temp = temperature if temperature is not None else self.temperature
468
+
469
+ # Estimate input tokens (messages + system, Gemini sends them separately)
470
+ estimated_input_tokens = count_messages_tokens(messages, self.model_type)
471
+ if system:
472
+ # Add system message token estimate
473
+ estimated_input_tokens += count_messages_tokens([{"role": "system", "content": system}], self.model_type)
474
+
475
+ # Clamp max_tokens to model limits (single canonical logic on base)
476
+ max_tokens_value = self._adjust_max_tokens(
477
+ messages=messages,
478
+ max_tokens=max_tokens,
479
+ estimated_input_tokens=estimated_input_tokens,
480
+ )
481
+
482
+ # Log request details with proper values being used
483
+ self.logger.info(f"Gemini API request: model={self.model_type}, temperature={temp}, max_tokens={max_tokens_value}, est_input_tokens={estimated_input_tokens}")
484
+
485
+ # Filter kwargs to only include supported parameters
486
+ supported_params = {
487
+ 'safety_settings', 'top_p', 'top_k', 'stop_sequences', 'stream',
488
+ 'response_mime_type', 'candidate_count'
489
+ }
490
+
491
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k in supported_params}
492
+
493
+ # Get the Gemini model
494
+ model = genai.GenerativeModel(
495
+ model_name=self.model_type,
496
+ generation_config={
497
+ "temperature": temp,
498
+ "max_output_tokens": max_tokens_value,
499
+ **filtered_kwargs
500
+ },
501
+ system_instruction=system_instruction_text if system_instruction_text else None # H1 FIX
502
+ )
503
+
504
+ # Make API request using native async method
505
+ # FIX (Dec 2, 2025): Use generate_content_async instead of to_thread wrapper
506
+ response = await model.generate_content_async(gemini_messages)
507
+
508
+ self.last_response = response
509
+
510
+ # Mark as successful
511
+ success = True
512
+
513
+ # Process response with robust error handling
514
+ # FIX (Dec 2, 2025): Handle ProtoType DESCRIPTOR errors from protobuf version mismatches
515
+ response_text = ""
516
+ try:
517
+ if hasattr(response, 'text'):
518
+ response_text = response.text
519
+ elif hasattr(response, 'parts'):
520
+ text_parts = []
521
+ for part in response.parts:
522
+ if hasattr(part, 'text') and part.text:
523
+ text_parts.append(part.text)
524
+ response_text = " ".join(text_parts)
525
+ except AttributeError as proto_err:
526
+ # Handle protobuf version mismatch errors
527
+ if 'DESCRIPTOR' in str(proto_err) or 'ProtoType' in str(proto_err):
528
+ self.logger.warning(f"Protobuf version mismatch, trying fallback: {proto_err}")
529
+ # Try to access raw response data as fallback
530
+ try:
531
+ if hasattr(response, '_result') and hasattr(response._result, 'candidates'):
532
+ for candidate in response._result.candidates:
533
+ if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
534
+ for part in candidate.content.parts:
535
+ if hasattr(part, 'text'):
536
+ response_text += part.text
537
+ except Exception as fallback_err:
538
+ self.logger.error(f"Fallback response extraction also failed: {fallback_err}")
539
+ raise ServiceError(f"Failed to extract response due to protobuf version mismatch: {proto_err}")
540
+ else:
541
+ raise
542
+
543
+ # Capture telemetry with actual timing and usage data
544
+ self._extract_usage_and_capture_telemetry(start_time, success, None, kwargs.get('metadata'))
545
+
546
+ return response_text
547
+
548
+ except Exception as e:
549
+ success = False
550
+ error_message = str(e)
551
+ self.logger.error(f"Gemini API error: {e}")
552
+
553
+ # Capture telemetry for failed requests too
554
+ self._extract_usage_and_capture_telemetry(start_time, success, error_message, kwargs.get('metadata'))
555
+
556
+ raise ServiceError(f"Gemini API error: {e}")
557
+
558
+ async def generate_response(
559
+ self,
560
+ prompt: Optional[Union[str, Dict[str, Any]]] = None,
561
+ messages: Optional[List[Dict[str, str]]] = None,
562
+ system: Optional[str] = None,
563
+ temperature: Optional[float] = None,
564
+ max_tokens: Optional[int] = None,
565
+ metadata: Optional[Dict[str, Any]] = None,
566
+ **kwargs
567
+ ) -> str:
568
+ """Generate a response from the LLM."""
569
+ try:
570
+ # Handle prompt formats
571
+ if messages is not None:
572
+ # Use messages directly if provided
573
+ formatted_messages = messages
574
+ elif isinstance(prompt, list) and all(isinstance(m, dict) for m in prompt):
575
+ # Prompt is already a list of messages
576
+ formatted_messages = prompt
577
+ elif isinstance(prompt, dict) and 'messages' in prompt:
578
+ # Extract messages and system from prompt dict
579
+ formatted_messages = prompt['messages']
580
+ if 'system' in prompt and not system:
581
+ system = prompt['system']
582
+ elif isinstance(prompt, str):
583
+ # Convert string prompt to message format
584
+ formatted_messages = [{"role": "user", "content": prompt}]
585
+ else:
586
+ # Default to empty message
587
+ formatted_messages = [{"role": "user", "content": "Hello"}]
588
+
589
+ # Gemini doesn't support metadata in the same way as other providers
590
+ # Convert metadata to a more standard format if needed
591
+ filtered_kwargs = kwargs.copy()
592
+
593
+ # Generate response
594
+ response = await self._generate(
595
+ messages=formatted_messages,
596
+ system=system,
597
+ temperature=temperature,
598
+ max_tokens=max_tokens,
599
+ **filtered_kwargs
600
+ )
601
+
602
+ return response
603
+
604
+ except Exception as e:
605
+ self.logger.error(f"Failed to generate response: {e}")
606
+ raise ServiceError(f"Failed to generate response: {e}")
607
+
608
+ async def _validate_connection(self) -> None:
609
+ """Validate connection to Gemini."""
610
+ try:
611
+ # Simple test completion
612
+ model = genai.GenerativeModel(
613
+ model_name=self.model_type,
614
+ generation_config={
615
+ "max_output_tokens": 10
616
+ }
617
+ )
618
+
619
+ # Use native async method for validation
620
+ response = await model.generate_content_async("Test connection")
621
+
622
+ if not response:
623
+ raise LLMConnectionError("No response from Gemini API")
624
+ self.logger.debug("Gemini API connection validated successfully")
625
+ except Exception as e:
626
+ # Route through the unified classifier to detect rate-limit signals
627
+ # (429, Resource exhausted, quota) — these are temporary; suppress and
628
+ # continue initialisation so the client is available for real requests.
629
+ translated = translate_llm_error(e, "Gemini validation")
630
+ if isinstance(translated, LLMRateLimitError):
631
+ self.logger.warning(
632
+ f"Gemini API temporarily rate-limited during validation (429). "
633
+ f"Client will still be initialized and will retry on actual requests: {e}"
634
+ )
635
+ return # Continue with initialization
636
+
637
+ # For other errors, fail initialization
638
+ self.logger.error(f"Failed to validate Gemini connection: {e}")
639
+ raise ServiceError(f"Failed to validate Gemini connection: {e}")
640
+
641
+ async def _cleanup_client(self) -> None:
642
+ """Clean up Gemini client."""
643
+ # No specific cleanup needed for Gemini client
644
+ self.logger.debug("Gemini client resources released")
645
+
646
+ async def cleanup(self) -> None:
647
+ """Clean up resources."""
648
+ self._delete_cached_content() # UP-08: never orphan a billed cachedContents object
649
+ await self._cleanup_client()
650
+ self._initialized = False
651
+ self.logger.info("Gemini client cleaned up")
652
+
653
+ async def _make_validation_request(self) -> Any:
654
+ """Make minimal test request to Gemini."""
655
+ model = genai.GenerativeModel(
656
+ model_name=self.model_type,
657
+ generation_config={"max_output_tokens": 1}
658
+ )
659
+ return await model.generate_content_async("test")
660
+
661
+ def _check_validation_response(self, response: Any) -> None:
662
+ """Validate Gemini response."""
663
+ if not response:
664
+ raise ValueError("Invalid response from Gemini")
665
+
666
+ async def generate_agent_response(
667
+ self,
668
+ messages: List[Dict[str, Any]],
669
+ tools: List[Dict[str, Any]],
670
+ **kwargs
671
+ ) -> Union[Tuple[str, List[Dict[str, Any]], Dict[str, Optional[int]]], str]:
672
+ """Generate response with tool-calling support for agents.
673
+
674
+ This method bridges the adapter contract to enable native tool-calling
675
+ for Gemini. It delegates to _generate_with_tools which handles the
676
+ wire-format conversion and API interaction.
677
+
678
+ Args:
679
+ messages: List of message dictionaries
680
+ tools: List of tool schemas in provider format
681
+ **kwargs: Additional generation parameters (system, temperature, etc.)
682
+
683
+ Returns:
684
+ Either:
685
+ - Tuple of (content, tool_calls, usage_data) when tool calls are present
686
+ usage_data dict contains: prompt_tokens, completion_tokens, total_tokens
687
+ - Just the content string when no tool calls
688
+
689
+ Raises:
690
+ LLMError: If generation fails
691
+ """
692
+ try:
693
+ self.logger.info(f"[generate_agent_response] Called with {len(messages)} messages and {len(tools) if tools else 0} tools")
694
+
695
+ # Extract system message if present in kwargs
696
+ system = kwargs.pop('system', None)
697
+ temperature = kwargs.pop('temperature', None)
698
+ max_tokens = kwargs.pop('max_tokens', None)
699
+
700
+ # Call the existing _generate_with_tools method
701
+ result = await self._generate_with_tools(
702
+ messages=messages,
703
+ tools=tools,
704
+ system=system,
705
+ temperature=temperature,
706
+ max_tokens=max_tokens,
707
+ **kwargs
708
+ )
709
+
710
+ # Log the result type for debugging
711
+ if isinstance(result, tuple):
712
+ if len(result) == 3:
713
+ _, tool_calls, _ = result
714
+ self.logger.info(f"[generate_agent_response] Returning 3-tuple with {len(tool_calls)} tool calls")
715
+ elif len(result) == 2:
716
+ _, tool_calls = result
717
+ self.logger.warning(f"[generate_agent_response] Got 2-tuple (old format) with {len(tool_calls)} tool calls")
718
+ else:
719
+ self.logger.info(f"[generate_agent_response] Returning content only")
720
+
721
+ return result
722
+
723
+ except LLMError:
724
+ # Re-raise LLM errors as-is
725
+ raise
726
+ except Exception as e:
727
+ # Wrap other exceptions using core.exceptions pattern
728
+ self.logger.error(f"generate_agent_response failed: {str(e)}", exc_info=True)
729
+ raise LLMError(f"Failed to generate agent response: {str(e)}")
730
+
731
+ async def _generate_with_tools(
732
+ self,
733
+ messages: List[Dict[str, Any]],
734
+ tools: Optional[List[Dict[str, Any]]] = None,
735
+ system: Optional[str] = None, # ✅ ADD THIS
736
+ temperature: Optional[float] = None, # ✅ ADD THIS
737
+ max_tokens: Optional[int] = None, # ✅ ADD THIS
738
+ metadata: Optional[Dict[str, Any]] = None,
739
+ **kwargs
740
+ ) -> Union[str, Tuple[str, List[Dict[str, Any]]]]:
741
+ """Generate response with tools support.
742
+
743
+ Args:
744
+ messages: List of messages
745
+ tools: List of tools
746
+ system: System prompt (CRITICAL for brain state instructions)
747
+ temperature: Temperature setting
748
+ max_tokens: Max completion tokens
749
+ metadata: Optional metadata
750
+
751
+ Returns:
752
+ Response text or tuple of (text, tool_calls)
753
+ """
754
+ try:
755
+ if not tools:
756
+ # If no tools, use standard generation.
757
+ # H1 FIX: forward system/temperature/max_tokens so the system prompt
758
+ # isn't lost on the no-tools fallthrough.
759
+ return await self._generate(
760
+ messages=messages,
761
+ system=system,
762
+ temperature=temperature,
763
+ max_tokens=max_tokens,
764
+ **kwargs
765
+ )
766
+
767
+ # Ensure client is initialized
768
+ if not self._initialized:
769
+ await self._initialize()
770
+
771
+ # Convert messages to Gemini format
772
+ gemini_messages = []
773
+
774
+ # ✅ FIX: Handle system message separately (skip from messages, prepend to content)
775
+ system_content = system # Use explicit system parameter
776
+
777
+ for msg in messages:
778
+ role = msg.get('role', '')
779
+ content = msg.get('content', '')
780
+
781
+ # Skip system messages - handle via system parameter
782
+ if role == 'system':
783
+ if not system_content:
784
+ system_content = content if isinstance(content, str) else str(content)
785
+ continue
786
+
787
+ # FIX (Dec 3, 2025): Handle tool response messages
788
+ # For Gemini 3: FunctionResponse parts require thought_signature, which we don't
789
+ # have when replaying history. Convert to text representation instead.
790
+ # For non-Gemini-3: Use proper glm.Part with FunctionResponse
791
+ if role == 'tool':
792
+ tool_call_id = msg.get('tool_call_id', '')
793
+ tool_name = msg.get('name', '')
794
+ func_name = tool_name if tool_name else f"tool_{tool_call_id}"
795
+
796
+ # For Gemini 3, always use text to avoid thought_signature requirement
797
+ if self._is_gemini_3_model():
798
+ # Convert tool response to text representation
799
+ tool_result_text = f"[Tool Result ({func_name})]: {content}"
800
+ if gemini_messages and gemini_messages[-1]["role"] == "user":
801
+ gemini_messages[-1]["parts"].append({"text": tool_result_text})
802
+ else:
803
+ gemini_messages.append({
804
+ "role": "user",
805
+ "parts": [{"text": tool_result_text}]
806
+ })
807
+ else:
808
+ # Non-Gemini-3: Use proper FunctionResponse parts
809
+ try:
810
+ function_response = glm.FunctionResponse(
811
+ name=func_name,
812
+ response={"result": content}
813
+ )
814
+ part = glm.Part(function_response=function_response)
815
+
816
+ if gemini_messages and gemini_messages[-1]["role"] == "user":
817
+ gemini_messages[-1]["parts"].append(part)
818
+ else:
819
+ gemini_messages.append({
820
+ "role": "user",
821
+ "parts": [part]
822
+ })
823
+ except Exception as e:
824
+ self.logger.warning(f"[HISTORY] Failed to create FunctionResponse part: {e}")
825
+ fallback_text = f"[Tool Response for {func_name}]: {content}"
826
+ if gemini_messages and gemini_messages[-1]["role"] == "user":
827
+ gemini_messages[-1]["parts"].append({"text": fallback_text})
828
+ else:
829
+ gemini_messages.append({
830
+ "role": "user",
831
+ "parts": [{"text": fallback_text}]
832
+ })
833
+ continue
834
+
835
+ # Map roles to Gemini format
836
+ if role == 'user':
837
+ gemini_role = 'user'
838
+ elif role in ['assistant', 'ai']:
839
+ gemini_role = 'model'
840
+
841
+ # FIX (Dec 3, 2025): Handle assistant messages with tool_calls
842
+ # For Gemini 3: FunctionCall parts require thought_signature, which we don't
843
+ # have when replaying history. Convert to text representation instead.
844
+ # For non-Gemini-3: Use proper glm.Part with FunctionCall
845
+ if 'tool_calls' in msg and msg['tool_calls']:
846
+ # Check if this is Gemini 3 - if so, convert to text to avoid thought_signature requirement
847
+ if self._is_gemini_3_model():
848
+ # Convert tool calls to text representation for Gemini 3
849
+ tool_calls_text = []
850
+ for tc in msg['tool_calls']:
851
+ if isinstance(tc, dict):
852
+ tc_name = tc.get('name') or (tc.get('function', {}).get('name') if 'function' in tc else 'unknown')
853
+ tc_args = tc.get('args') or tc.get('arguments') or (tc.get('function', {}).get('arguments') if 'function' in tc else {})
854
+ if isinstance(tc_args, str):
855
+ try:
856
+ tc_args = json.loads(tc_args) if tc_args else {}
857
+ except (json.JSONDecodeError, ValueError):
858
+ pass
859
+ tool_calls_text.append(f"[Called {tc_name} with args: {tc_args}]")
860
+
861
+ combined_text = content if content else ""
862
+ if tool_calls_text:
863
+ combined_text = (combined_text + "\n" if combined_text else "") + "\n".join(tool_calls_text)
864
+
865
+ if combined_text.strip():
866
+ gemini_messages.append({
867
+ "role": gemini_role,
868
+ "parts": [{"text": combined_text}]
869
+ })
870
+ continue
871
+ else:
872
+ # Non-Gemini-3: Use proper FunctionCall parts
873
+ parts = []
874
+
875
+ # Add text content if present
876
+ if content:
877
+ text_content = content if isinstance(content, str) else str(content)
878
+ if text_content.strip():
879
+ parts.append(glm.Part(text=text_content))
880
+
881
+ # Add function call parts
882
+ for tc in msg['tool_calls']:
883
+ if isinstance(tc, dict):
884
+ tc_name = tc.get('name') or (tc.get('function', {}).get('name') if 'function' in tc else None)
885
+ tc_args = tc.get('args') or tc.get('arguments') or (tc.get('function', {}).get('arguments') if 'function' in tc else {})
886
+
887
+ if isinstance(tc_args, str):
888
+ try:
889
+ tc_args = json.loads(tc_args) if tc_args else {}
890
+ except (json.JSONDecodeError, ValueError):
891
+ tc_args = {"raw": tc_args}
892
+
893
+ try:
894
+ function_call = glm.FunctionCall(name=tc_name, args=tc_args)
895
+ part = glm.Part(function_call=function_call)
896
+ parts.append(part)
897
+ except Exception as e:
898
+ self.logger.warning(f"[HISTORY] Failed to create FunctionCall part: {e}")
899
+ continue
900
+
901
+ if parts:
902
+ gemini_messages.append({
903
+ "role": gemini_role,
904
+ "parts": parts
905
+ })
906
+ continue
907
+ else:
908
+ self.logger.warning(f"Unknown role {role}, treating as user")
909
+ gemini_role = 'user'
910
+
911
+ # Convert content to Gemini parts format
912
+ parts = self._convert_content_to_parts(content)
913
+
914
+ # Skip if no valid parts
915
+ if not parts:
916
+ continue
917
+
918
+ # ✅ FIX: Merge consecutive messages with same role
919
+ # Gemini requires alternating user/model messages
920
+ if gemini_messages and gemini_messages[-1]["role"] == gemini_role:
921
+ # Append to existing message with same role
922
+ gemini_messages[-1]["parts"].extend(parts)
923
+ else:
924
+ # Create new message
925
+ gemini_messages.append({
926
+ "role": gemini_role,
927
+ "parts": parts
928
+ })
929
+
930
+ # ✅ Issue #5 fix: Use native system_instruction instead of injecting messages
931
+ if system_content:
932
+ self.logger.debug(f"Using native system_instruction ({len(system_content)} chars)")
933
+
934
+ # ✅ FIX: Use explicit parameters
935
+ temp = temperature if temperature is not None else self.temperature
936
+
937
+ # Estimate input tokens (messages + system_content, Gemini sends them separately)
938
+ estimated_input_tokens = count_messages_tokens(messages, self.model_type)
939
+ if system_content:
940
+ # Add system message token estimate
941
+ estimated_input_tokens += count_messages_tokens([{"role": "system", "content": system_content}], self.model_type)
942
+
943
+ # Clamp max_tokens to model limits (single canonical logic on base)
944
+ max_tokens_value = self._adjust_max_tokens(
945
+ messages=messages,
946
+ max_tokens=max_tokens,
947
+ estimated_input_tokens=estimated_input_tokens,
948
+ )
949
+
950
+ self.logger.info(f"Gemini API request with tools: model={self.model_type}, tools={len(tools)}, max_tokens={max_tokens_value}, has_system={bool(system_content)}")
951
+
952
+ # Convert tools to Gemini format
953
+ gemini_tools = []
954
+
955
+ for tool in tools:
956
+ # ✅ Issue #6 fix: Validate before converting
957
+ if not self._validate_tool_schema(tool):
958
+ self.logger.warning(f"Skipping invalid tool schema: {tool.get('name', 'unknown') if isinstance(tool, dict) else getattr(tool, 'name', 'unknown')}")
959
+ continue
960
+ # Handle different tool formats (OpenAI, etc.)
961
+ if isinstance(tool, dict):
962
+ # Already in Gemini format: {'function_declarations': [{...}]}
963
+ if 'function_declarations' in tool:
964
+ # Tool is already in Gemini format, use directly
965
+ gemini_tools.append(tool)
966
+ # OpenAI format: {'type': 'function', 'function': {...}}
967
+ elif 'type' in tool and tool['type'] == 'function':
968
+ gemini_tools.append({
969
+ "function_declarations": [
970
+ {
971
+ "name": tool['function']['name'],
972
+ "description": tool['function'].get('description', ''),
973
+ "parameters": tool['function'].get('parameters', {})
974
+ }
975
+ ]
976
+ })
977
+ # Direct format: {'name': ..., 'description': ..., 'parameters': ...}
978
+ elif 'name' in tool:
979
+ gemini_tools.append({
980
+ "function_declarations": [
981
+ {
982
+ "name": tool['name'],
983
+ "description": tool.get('description', ''),
984
+ "parameters": tool.get('parameters', tool.get('args_schema', {}))
985
+ }
986
+ ]
987
+ })
988
+ else:
989
+ self.logger.warning(f"Unknown tool format: {tool}")
990
+ else:
991
+ # Tool object
992
+ if hasattr(tool, 'name'):
993
+ parameters = {}
994
+ if hasattr(tool, 'args_schema'):
995
+ # Convert Pydantic model to JSON schema
996
+ if tool.args_schema:
997
+ parameters = tool.args_schema.model_json_schema()
998
+ elif hasattr(tool, 'args'):
999
+ parameters = tool.args
1000
+
1001
+ gemini_tools.append({
1002
+ "function_declarations": [
1003
+ {
1004
+ "name": tool.name,
1005
+ "description": getattr(tool, 'description', ''),
1006
+ "parameters": parameters
1007
+ }
1008
+ ]
1009
+ })
1010
+ else:
1011
+ self.logger.warning(f"Unknown tool object type: {type(tool)}")
1012
+
1013
+ # Convert dict-based tools to proper glm.Tool objects
1014
+ tool_objects = []
1015
+ if gemini_tools:
1016
+ for tool_dict in gemini_tools:
1017
+ if isinstance(tool_dict, dict) and 'function_declarations' in tool_dict:
1018
+ function_declarations = []
1019
+ for func in tool_dict['function_declarations']:
1020
+ # Convert parameters dict to glm.Schema recursively
1021
+ def dict_to_schema(params_dict: Dict) -> glm.Schema:
1022
+ """Recursively convert dict to glm.Schema"""
1023
+ schema_kwargs = {}
1024
+
1025
+ # Handle type
1026
+ if 'type' in params_dict:
1027
+ type_map = {
1028
+ 'object': glm.Type.OBJECT,
1029
+ 'string': glm.Type.STRING,
1030
+ 'integer': glm.Type.INTEGER,
1031
+ 'number': glm.Type.NUMBER,
1032
+ 'boolean': glm.Type.BOOLEAN,
1033
+ 'array': glm.Type.ARRAY,
1034
+ }
1035
+ schema_kwargs['type'] = type_map.get(params_dict['type'], glm.Type.STRING)
1036
+
1037
+ # Handle description
1038
+ if 'description' in params_dict:
1039
+ schema_kwargs['description'] = params_dict['description']
1040
+
1041
+ # Handle properties (nested schemas)
1042
+ if 'properties' in params_dict:
1043
+ schema_kwargs['properties'] = {
1044
+ k: dict_to_schema(v) for k, v in params_dict['properties'].items()
1045
+ }
1046
+
1047
+ # Handle required
1048
+ if 'required' in params_dict:
1049
+ schema_kwargs['required'] = params_dict['required']
1050
+
1051
+ # Handle items (for arrays)
1052
+ if 'items' in params_dict:
1053
+ schema_kwargs['items'] = dict_to_schema(params_dict['items'])
1054
+
1055
+ # Handle enum
1056
+ if 'enum' in params_dict:
1057
+ schema_kwargs['enum'] = params_dict['enum']
1058
+
1059
+ return glm.Schema(**schema_kwargs)
1060
+
1061
+ # Create FunctionDeclaration
1062
+ try:
1063
+ function_declarations.append(
1064
+ glm.FunctionDeclaration(
1065
+ name=func['name'],
1066
+ description=func.get('description', ''),
1067
+ parameters=dict_to_schema(func.get('parameters', {'type': 'object'}))
1068
+ )
1069
+ )
1070
+ except Exception as e:
1071
+ self.logger.error(f"Failed to convert function '{func.get('name', 'unknown')}' to schema: {e}")
1072
+ self.logger.error(f"Function parameters: {func.get('parameters', {})}")
1073
+ # Skip this function
1074
+ continue
1075
+
1076
+ # Create Tool object
1077
+ tool_objects.append(glm.Tool(function_declarations=function_declarations))
1078
+ self.logger.debug(f"Created glm.Tool with {len(function_declarations)} function declarations")
1079
+
1080
+ # Create generation config (extracted for reuse in retry logic)
1081
+ generation_config = {
1082
+ "temperature": temp,
1083
+ "max_output_tokens": max_tokens_value,
1084
+ }
1085
+
1086
+ # UP-08: opt-in explicit cachedContents for the stable system+tools prefix.
1087
+ # When a cache is in use, system_instruction/tools live IN the cache and must
1088
+ # NOT be re-sent. Scoped to the non-Gemini-3 path (see _maybe_build_cached_content).
1089
+ cached = self._maybe_build_cached_content(system_content, tool_objects)
1090
+ if cached is not None:
1091
+ model = genai.GenerativeModel.from_cached_content(
1092
+ cached_content=cached,
1093
+ generation_config=generation_config,
1094
+ )
1095
+ else:
1096
+ # Create model with tools (uncached default path)
1097
+ model = genai.GenerativeModel(
1098
+ model_name=self.model_type,
1099
+ generation_config=generation_config,
1100
+ tools=tool_objects if tool_objects else None,
1101
+ system_instruction=system_content if system_content else None # ✅ Issue #5 fix: native support
1102
+ )
1103
+
1104
+ # FIX (Dec 2, 2025): Use ChatSession for proper thought signature handling
1105
+ # Gemini 3 requires thought signatures for function calling. The SDK's ChatSession
1106
+ # handles this automatically by preserving the full response content including signatures.
1107
+ # We use start_chat with history for multi-turn conversations.
1108
+ # See: https://ai.google.dev/gemini-api/docs/function-calling
1109
+ use_chat_session = self._is_gemini_3_model() and tool_objects
1110
+
1111
+ # Log API request details
1112
+ func_count = sum(len(t.function_declarations) for t in tool_objects if hasattr(t, 'function_declarations')) if tool_objects else 0
1113
+ self.logger.info(
1114
+ f"Gemini API call: model={self.model_type}, messages={len(gemini_messages)}, "
1115
+ f"functions={func_count}, system={len(system_content) if system_content else 0}chars, "
1116
+ f"mode={'ChatSession' if use_chat_session else 'generate_content'}"
1117
+ )
1118
+
1119
+ # Debug: Log function names
1120
+ if tool_objects and self.logger.isEnabledFor(logging.DEBUG):
1121
+ func_names = [f.name for t in tool_objects if hasattr(t, 'function_declarations') for f in t.function_declarations]
1122
+ self.logger.debug(f"Available functions: {func_names}")
1123
+
1124
+ # FIX (Dec 2, 2025): Use ChatSession for Gemini 3 to handle thought signatures
1125
+ # The SDK's ChatSession automatically preserves thought signatures across turns
1126
+ try:
1127
+ if use_chat_session:
1128
+ # For Gemini 3 with tools: use ChatSession which handles thought signatures
1129
+ # Split messages into history (all but last) and current message (last)
1130
+ if len(gemini_messages) > 1:
1131
+ history = gemini_messages[:-1]
1132
+ current_message = gemini_messages[-1]
1133
+ else:
1134
+ history = []
1135
+ current_message = gemini_messages[0] if gemini_messages else {"role": "user", "parts": [{"text": ""}]}
1136
+
1137
+ # Create chat session with history
1138
+ chat = model.start_chat(history=history)
1139
+
1140
+ # Send the current message - SDK handles thought signatures automatically
1141
+ response = await asyncio.wait_for(
1142
+ chat.send_message_async(current_message["parts"]),
1143
+ timeout=float(self.DEFAULT_REQUEST_TIMEOUT)
1144
+ )
1145
+ self.logger.debug(f"[CHAT_SESSION] Used ChatSession for Gemini 3 thought signature handling")
1146
+ else:
1147
+ # Standard path for non-Gemini-3 or no tools
1148
+ response = await asyncio.wait_for(
1149
+ model.generate_content_async(gemini_messages),
1150
+ timeout=float(self.DEFAULT_REQUEST_TIMEOUT)
1151
+ )
1152
+ except asyncio.TimeoutError:
1153
+ self.logger.error(f"Gemini API call timed out after {self.DEFAULT_REQUEST_TIMEOUT} seconds")
1154
+ self.logger.error(f"[DEBUG] Request details: messages={len(gemini_messages)}, tools={len(tool_objects)}, system={bool(system_content)}")
1155
+ # Log first message preview
1156
+ if gemini_messages:
1157
+ msg_preview = str(gemini_messages[0])[:500]
1158
+ self.logger.error(f"[DEBUG] First message preview: {msg_preview}")
1159
+
1160
+ # Try without tools as fallback
1161
+ if tool_objects:
1162
+ self.logger.warning(f"[DEBUG] Retrying without tools to isolate issue...")
1163
+ try:
1164
+ model_no_tools = genai.GenerativeModel(
1165
+ model_name=self.model_type,
1166
+ generation_config=generation_config,
1167
+ system_instruction=system_content if system_content else None
1168
+ )
1169
+ response = await asyncio.wait_for(
1170
+ model_no_tools.generate_content_async(gemini_messages),
1171
+ timeout=float(self.DEFAULT_REQUEST_TIMEOUT)
1172
+ )
1173
+ self.logger.warning(f"[DEBUG] SUCCESS without tools! Tools are the problem.")
1174
+ # Return text-only response
1175
+ text = ""
1176
+ if hasattr(response, 'text'):
1177
+ text = response.text
1178
+ return text, []
1179
+ except Exception as e2:
1180
+ self.logger.error(f"[DEBUG] Also failed without tools: {e2}")
1181
+
1182
+ raise ServiceError(f"Gemini API call timed out after {self.DEFAULT_REQUEST_TIMEOUT} seconds")
1183
+ except Exception as e:
1184
+ self.logger.error(f"Gemini API call failed with exception: {type(e).__name__}: {e}")
1185
+ self.logger.error(f"[DEBUG] Request details: messages={len(gemini_messages)}, tools={len(tool_objects)}, system={bool(system_content)}")
1186
+ raise ServiceError(f"Gemini API call failed: {e}")
1187
+
1188
+ self.logger.info(f"[DEBUG] Gemini API call completed successfully")
1189
+
1190
+ self.last_response = response
1191
+
1192
+ # Check if response contains function calls
1193
+ tool_calls = []
1194
+ response_text = ""
1195
+
1196
+ if hasattr(response, 'candidates') and response.candidates:
1197
+ candidate = response.candidates[0]
1198
+ if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
1199
+ for i, part in enumerate(candidate.content.parts):
1200
+ # FIX (Dec 2, 2025): Check for thought_signature on the part itself
1201
+ # Gemini 3 returns thought_signature alongside function_call
1202
+ # We extract and include it in tool_call_data for preservation
1203
+ part_thought_sig = getattr(part, 'thought_signature', None)
1204
+ if part_thought_sig:
1205
+ self.logger.debug(f"[THOUGHT_SIG] Found thought_signature on part {i}: {str(part_thought_sig)[:50]}...")
1206
+
1207
+ if hasattr(part, 'function_call'):
1208
+ function_call = part.function_call
1209
+
1210
+ # Extract name with validation
1211
+ name = getattr(function_call, 'name', None)
1212
+ if not name or not name.strip():
1213
+ # ENHANCED DEBUG (Dec 2025): Log full structure to diagnose malformed calls
1214
+ # FIX (Dec 2, 2025): Also log if there's a thought_signature - that helps diagnose
1215
+ self.logger.error(
1216
+ f"Gemini function_call missing name. "
1217
+ f"Type: {type(function_call).__name__}, "
1218
+ f"Dir: {[a for a in dir(function_call) if not a.startswith('_')]}, "
1219
+ f"Repr: {repr(function_call)[:500]}, "
1220
+ f"thought_signature_present: {part_thought_sig is not None}"
1221
+ )
1222
+ # Try alternative attribute names that Gemini might use
1223
+ alt_name = (
1224
+ getattr(function_call, 'function_name', None) or
1225
+ getattr(function_call, 'tool_name', None) or
1226
+ getattr(function_call, 'method', None)
1227
+ )
1228
+ if alt_name:
1229
+ self.logger.info(f"Found alternative name attribute: {alt_name}")
1230
+ name = alt_name
1231
+ else:
1232
+ continue # Skip invalid tool calls
1233
+
1234
+ # Extract args as dict (avoid JSON serialization)
1235
+ raw_args = getattr(function_call, 'args', None)
1236
+ if raw_args:
1237
+ # Convert protobuf Struct to dict
1238
+ try:
1239
+ # Struct has .items() like a dict
1240
+ args_dict = {k: v for k, v in raw_args.items()} if hasattr(raw_args, 'items') else {}
1241
+ except Exception as e:
1242
+ self.logger.error(f"Failed to convert Gemini args to dict: {e}")
1243
+ args_dict = {}
1244
+ else:
1245
+ args_dict = {}
1246
+
1247
+ # FIX (Dec 2, 2025): Include thought_signature in tool call for Gemini 3
1248
+ tool_call_data = {
1249
+ "id": f"gemini_call_{uuid4()}", # ✅ Issue #1 fix: unique IDs
1250
+ "type": "function",
1251
+ "function": {
1252
+ "name": name,
1253
+ "arguments": args_dict # ✅ Issue #2 fix: keep as dict
1254
+ }
1255
+ }
1256
+
1257
+ # Include thought_signature if present (Gemini 3 requirement)
1258
+ if part_thought_sig:
1259
+ tool_call_data["thought_signature"] = part_thought_sig
1260
+ self.logger.debug(f"[THOUGHT_SIG] Attached to tool call '{name}'")
1261
+
1262
+ tool_calls.append(tool_call_data)
1263
+ elif hasattr(part, 'text'):
1264
+ response_text += part.text
1265
+
1266
+ # Extract usage data
1267
+ usage_data = self._extract_usage_data()
1268
+
1269
+ # Return tool calls if any were found
1270
+ if tool_calls:
1271
+ # Gemini sometimes returns tool calls without content in native tool mode
1272
+ if not response_text or not response_text.strip():
1273
+ self.logger.debug(
1274
+ f"Gemini returned tool calls without content (expected in native tool mode). "
1275
+ f"Tool calls: {[tc['function']['name'] for tc in tool_calls[:3]]}{'...' if len(tool_calls) > 3 else ''}"
1276
+ )
1277
+ return response_text, tool_calls, usage_data
1278
+
1279
+ # If no function calls, return text response with empty tool calls list
1280
+ if hasattr(response, 'text'):
1281
+ return response.text, [], usage_data
1282
+ elif hasattr(response, 'parts'):
1283
+ text_parts = []
1284
+ for part in response.parts:
1285
+ if hasattr(part, 'text') and part.text:
1286
+ text_parts.append(part.text)
1287
+ return " ".join(text_parts), [], usage_data
1288
+
1289
+ return "", [], usage_data
1290
+
1291
+ except Exception as e:
1292
+ self.logger.error(f"Gemini API tool-based generation error: {e}")
1293
+ raise ServiceError(f"Gemini API tool-based generation error: {e}")
1294
+
1295
+ def _extract_usage_data(self) -> Dict[str, Optional[int]]:
1296
+ """Extract usage data from last_response.
1297
+
1298
+ Returns dict with prompt_tokens, completion_tokens, total_tokens.
1299
+ Falls back to estimation if usage data not available.
1300
+ ✅ Issue #4 fix: Never returns None - uses 0 as last resort.
1301
+
1302
+ Note: Gemini uses usage_metadata with different field names.
1303
+ """
1304
+ # Initialize defaults
1305
+ prompt_tokens = None
1306
+ completion_tokens = None
1307
+ total_tokens = None
1308
+ cached_tokens = 0 # UP-08: Gemini implicit/explicit cache hits
1309
+
1310
+ if not self.last_response:
1311
+ self.logger.warning("No last_response available for usage extraction")
1312
+ elif hasattr(self.last_response, 'usage_metadata') and self.last_response.usage_metadata:
1313
+ # Extract from Gemini usage_metadata
1314
+ usage = self.last_response.usage_metadata
1315
+ prompt_tokens = getattr(usage, 'prompt_token_count', None)
1316
+ completion_tokens = getattr(usage, 'candidates_token_count', None)
1317
+ total_tokens = getattr(usage, 'total_token_count', None)
1318
+ # UP-08: implicit (2.5+) and explicit cachedContents hits land here.
1319
+ cached_tokens = getattr(usage, 'cached_content_token_count', 0) or 0
1320
+
1321
+ # Calculate total if not provided
1322
+ if total_tokens is None and prompt_tokens is not None and completion_tokens is not None:
1323
+ total_tokens = prompt_tokens + completion_tokens
1324
+
1325
+ # Fallback: Estimate from content if no usage data
1326
+ if prompt_tokens is None or completion_tokens is None:
1327
+ try:
1328
+ # Extract text content for estimation
1329
+ content_text = ""
1330
+
1331
+ if hasattr(self.last_response, 'text') and self.last_response.text:
1332
+ content_text = self.last_response.text
1333
+ elif hasattr(self.last_response, 'candidates') and self.last_response.candidates:
1334
+ # Extract from candidates structure
1335
+ for candidate in self.last_response.candidates:
1336
+ if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
1337
+ for part in candidate.content.parts:
1338
+ if hasattr(part, 'text') and part.text:
1339
+ content_text += part.text
1340
+
1341
+ if content_text:
1342
+ # Rough estimation: ~4 characters per token
1343
+ estimated_output = len(content_text) // 4
1344
+ # Assume input is roughly same size as output
1345
+ estimated_input = estimated_output
1346
+
1347
+ prompt_tokens = estimated_input if prompt_tokens is None else prompt_tokens
1348
+ completion_tokens = estimated_output if completion_tokens is None else completion_tokens
1349
+ total_tokens = prompt_tokens + completion_tokens
1350
+
1351
+ self.logger.info(f"Estimated Gemini tokens: {total_tokens} (usage_metadata unavailable)")
1352
+ except Exception as e:
1353
+ self.logger.debug(f"Failed to estimate tokens: {e}")
1354
+
1355
+ # Final validation: Never return None - use 0 as last resort
1356
+ usage_data = {
1357
+ 'prompt_tokens': prompt_tokens if prompt_tokens is not None else 0,
1358
+ 'completion_tokens': completion_tokens if completion_tokens is not None else 0,
1359
+ 'total_tokens': total_tokens if total_tokens is not None else 0,
1360
+ 'cached_tokens': cached_tokens,
1361
+ }
1362
+
1363
+ if usage_data['total_tokens'] == 0:
1364
+ self.logger.warning("No usage data available and estimation failed - using 0 tokens")
1365
+
1366
+ self.logger.debug(f"Extracted usage: {usage_data}")
1367
+ return usage_data