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.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- 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
|