new feature: External guardrails/judges
This commit is contained in:
11
.pytest_cache/v/cache/lastfailed
vendored
11
.pytest_cache/v/cache/lastfailed
vendored
@@ -1,10 +1 @@
|
|||||||
{
|
{}
|
||||||
"agent_framework_oci/tests/unit/test_langgraph_checkpoint_saver.py::test_repository_checkpoint_saver_put_get": true,
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_integrity_envelope_and_recovery_skips_corrupt_latest": true,
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_compaction_keeps_last_n_checkpoints": true,
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_recovery_raises_when_only_corrupt_checkpoints_exist": true,
|
|
||||||
"agent_framework_oci/tests/unit/test_langgraph_checkpoint_saver.py::test_put_writes_does_not_restore_older_checkpoint": true,
|
|
||||||
"agent_framework_oci/tests/unit/test_agent_runtime.py::test_agent_runtime_without_cache_invokes_llm": true,
|
|
||||||
"tests/migration/test_guardrail_original_defaults_parity.py::test_original_default_guardrails_estao_ativos_no_pipeline_framework": true,
|
|
||||||
"tests/migration/test_guardrail_original_defaults_parity.py::test_framework_loader_instancia_coer_e_fraseologia": true
|
|
||||||
}
|
|
||||||
96
.pytest_cache/v/cache/nodeids
vendored
96
.pytest_cache/v/cache/nodeids
vendored
@@ -1,64 +1,14 @@
|
|||||||
[
|
[
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_generic_lines_mode_preserves_order_shape",
|
"agent_framework_oci/tests/test_phraseology_rewrite_revalidation.py::test_phraseology_block_is_rewritten_once_and_all_rails_are_revalidated",
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_generic_list_mode_supports_contas_multiple_plans_without_none",
|
"agent_framework_oci/tests/test_phraseology_rewrite_revalidation.py::test_phraseology_rewrite_does_not_loop_when_rewritten_text_is_still_blocked",
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_generic_template_mode_preserves_template_backend_plan_contract",
|
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_missing_renderer_falls_back_without_breaking_runtime",
|
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_no_declared_response_keeps_legacy_fallback",
|
|
||||||
"agent_framework_oci/tests/test_generic_tool_response_presentation.py::test_renderer_mode_uses_application_registered_renderer",
|
|
||||||
"agent_framework_oci/tests/test_judge_transaction_sampling.py::test_awaiting_confirmation_bypasses_sampling",
|
|
||||||
"agent_framework_oci/tests/test_judge_transaction_sampling.py::test_completed_transaction_bypasses_sampling_from_mcp_result",
|
|
||||||
"agent_framework_oci/tests/test_judge_transaction_sampling.py::test_non_transactional_turn_respects_zero_sample_rate",
|
|
||||||
"agent_framework_oci/tests/test_judge_transaction_sampling.py::test_transactional_detection_from_tool_policy",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_collection_turn_reuses_declarative_parameter_extraction",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_explicit_order_id_has_precedence_over_contract_key",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_new_transaction_current_message_overrides_stale_subject_from_context",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_runtime_extracts_order_id_from_current_message",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_transaction_extracts_required_fields_before_first_missing_check",
|
|
||||||
"agent_framework_oci/tests/test_mcp_parameter_extraction_runtime.py::test_unique_transactional_tool_is_selected_after_routing_constrains_allowlist",
|
|
||||||
"agent_framework_oci/tests/test_route_stickiness_transaction_shift.py::test_explicit_keyword_shift_preempts_stickiness",
|
|
||||||
"agent_framework_oci/tests/test_route_stickiness_transaction_shift.py::test_same_agent_explicit_intent_shift_must_preempt_stickiness",
|
|
||||||
"agent_framework_oci/tests/test_route_stickiness_transaction_shift.py::test_same_agent_transaction_keyword_preempts_continuity",
|
|
||||||
"agent_framework_oci/tests/test_route_stickiness_transaction_shift.py::test_short_generic_keyword_does_not_preempt",
|
|
||||||
"agent_framework_oci/tests/test_transaction_state_router_interruption.py::test_state_policy_allows_high_confidence_new_intent",
|
|
||||||
"agent_framework_oci/tests/test_transaction_state_router_interruption.py::test_state_policy_keeps_short_parameter_answer_in_transaction",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_closed_transaction_is_not_operational_context_for_next_turn",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_collecting_parameters_can_be_cancelled_explicitly",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_collecting_parameters_does_not_replace_collected_subject_with_stale_context",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_completed_transaction_becomes_reusable_operational_evidence",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_new_contestation_does_not_inherit_subject_or_value_from_previous_transaction",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_pre_validation_passes_then_waits_for_confirmation",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_pre_validation_rejection_clears_collecting_latches_and_is_exposed_in_patch",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_pre_validation_rejects_before_confirmation_without_executing_transaction",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_pre_validation_runs_after_last_required_parameter_before_confirmation",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_route_intent_shift_clears_collecting_transaction_before_new_tools",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_runtime_source_contains_persisted_confirmation_contract",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_terminal_confirmation_closes_active_transaction_and_clears_latches",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_tool_policy_registry_reads_pre_validation",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_tool_policy_registry_reads_transactional_confirmation",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_transaction_evidence_is_correlated_by_resource_identifier",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_transaction_state_patch_exposes_prevalidation_and_terminal_lifecycle",
|
|
||||||
"agent_framework_oci/tests/test_transactional_tool_flow.py::test_transaction_waits_then_executes_after_confirmation",
|
|
||||||
"agent_framework_oci/tests/unit/test_agent_runtime.py::test_agent_runtime_without_cache_invokes_llm",
|
|
||||||
"agent_framework_oci/tests/unit/test_langgraph_checkpoint_saver.py::test_put_writes_does_not_restore_older_checkpoint",
|
|
||||||
"agent_framework_oci/tests/unit/test_langgraph_checkpoint_saver.py::test_repository_checkpoint_saver_put_get",
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_compaction_keeps_last_n_checkpoints",
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_integrity_envelope_and_recovery_skips_corrupt_latest",
|
|
||||||
"agent_framework_oci/tests/unit/test_resilient_checkpointer.py::test_recovery_raises_when_only_corrupt_checkpoints_exist",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_continue_bypasses_router_without_regex_rules",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_continue_without_active_agent_falls_back_to_router",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_end_session_is_returned_as_global_route",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_global_session_actions_work_without_active_agent",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_human_handoff_is_returned_as_global_route",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_invalid_output_falls_back_safely",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_low_confidence_continue_falls_back_safely",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_no_active_agent_still_classifies_global_session_actions",
|
|
||||||
"agent_framework_oci/tests/unit/test_semantic_route_stickiness.py::test_route_result_falls_back_to_enterprise_router",
|
|
||||||
"tests/migration/test_adversarial_parity.py::test_anatel_rail_e_framework_native_sem_dependencia_legado",
|
"tests/migration/test_adversarial_parity.py::test_anatel_rail_e_framework_native_sem_dependencia_legado",
|
||||||
"tests/migration/test_adversarial_parity.py::test_cval_bloqueia_contestacao_antes_do_side_effect",
|
"tests/migration/test_adversarial_parity.py::test_cval_bloqueia_contestacao_antes_do_side_effect",
|
||||||
"tests/migration/test_adversarial_parity.py::test_finalizacao_informacional_infer_tipo_de_billing_analysis_json",
|
"tests/migration/test_adversarial_parity.py::test_finalizacao_informacional_infer_tipo_de_billing_analysis_json",
|
||||||
"tests/migration/test_adversarial_parity.py::test_finalizacao_informacional_infer_tipo_pela_fatura",
|
"tests/migration/test_adversarial_parity.py::test_finalizacao_informacional_infer_tipo_pela_fatura",
|
||||||
"tests/migration/test_adversarial_parity.py::test_fix_whole_utterance_transcription_preserva_fronteira_de_frase",
|
"tests/migration/test_adversarial_parity.py::test_fix_whole_utterance_transcription_preserva_fronteira_de_frase",
|
||||||
"tests/migration/test_adversarial_parity.py::test_output_guardrail_context_extrai_protocolos_das_tools",
|
"tests/migration/test_adversarial_parity.py::test_output_guardrail_context_extrai_protocolos_das_tools",
|
||||||
|
"tests/migration/test_adversarial_parity.py::test_output_guardrail_context_inclui_pedido_atual_para_aoferta",
|
||||||
|
"tests/migration/test_adversarial_parity.py::test_output_guardrail_context_nao_duplica_user_text_ja_no_historico",
|
||||||
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_e_short_circuit_framework",
|
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_e_short_circuit_framework",
|
||||||
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_preserva_terminal_status_e_fallback_sem_historico",
|
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_preserva_terminal_status_e_fallback_sem_historico",
|
||||||
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_status_invalido_usa_fallback_seguro",
|
"tests/migration/test_adversarial_parity.py::test_post_finalize_replay_status_invalido_usa_fallback_seguro",
|
||||||
@@ -185,6 +135,10 @@
|
|||||||
"tests/migration/test_dispute_actions_remaining_parity.py::test_sms_usa_barcode_titular_e_payload_padrao",
|
"tests/migration/test_dispute_actions_remaining_parity.py::test_sms_usa_barcode_titular_e_payload_padrao",
|
||||||
"tests/migration/test_dispute_actions_remaining_parity.py::test_status_sr_nao_faz_tracking_de_fatura_errada",
|
"tests/migration/test_dispute_actions_remaining_parity.py::test_status_sr_nao_faz_tracking_de_fatura_errada",
|
||||||
"tests/migration/test_dispute_actions_remaining_parity.py::test_status_sr_sms_path_e_tracking_completo",
|
"tests/migration/test_dispute_actions_remaining_parity.py::test_status_sr_sms_path_e_tracking_completo",
|
||||||
|
"tests/migration/test_external_guardrails_judges_spi.py::test_contas_config_substitui_policies_tim_por_extensoes",
|
||||||
|
"tests/migration/test_external_guardrails_judges_spi.py::test_external_judges_load_from_agent_config",
|
||||||
|
"tests/migration/test_external_guardrails_judges_spi.py::test_sync_external_rail_runs_in_worker_thread",
|
||||||
|
"tests/migration/test_external_guardrails_judges_spi.py::test_sync_judges_run_concurrently_in_threads",
|
||||||
"tests/migration/test_finalization_parity_extended.py::test_cpf_alias_is_forwarded_to_informational_protocol",
|
"tests/migration/test_finalization_parity_extended.py::test_cpf_alias_is_forwarded_to_informational_protocol",
|
||||||
"tests/migration/test_finalization_parity_extended.py::test_deferred_bundle_and_strategic_items_create_combined_rt15_without_workflow_latch",
|
"tests/migration/test_finalization_parity_extended.py::test_deferred_bundle_and_strategic_items_create_combined_rt15_without_workflow_latch",
|
||||||
"tests/migration/test_finalization_parity_extended.py::test_existing_session_protocol_prevents_duplicate_informational_rt15",
|
"tests/migration/test_finalization_parity_extended.py::test_existing_session_protocol_prevents_duplicate_informational_rt15",
|
||||||
@@ -236,7 +190,8 @@
|
|||||||
"tests/migration/test_guardrail_binary_parity.py::test_binary_polarity_contract",
|
"tests/migration/test_guardrail_binary_parity.py::test_binary_polarity_contract",
|
||||||
"tests/migration/test_guardrail_binary_parity.py::test_pinj_prompt_is_binary_and_keeps_examples",
|
"tests/migration/test_guardrail_binary_parity.py::test_pinj_prompt_is_binary_and_keeps_examples",
|
||||||
"tests/migration/test_guardrail_binary_parity.py::test_revprec_prompt_preserves_boundaries",
|
"tests/migration/test_guardrail_binary_parity.py::test_revprec_prompt_preserves_boundaries",
|
||||||
"tests/migration/test_guardrail_original_defaults_parity.py::test_framework_loader_instancia_coer_e_fraseologia",
|
"tests/migration/test_guardrail_context_dict_history.py::test_format_context_block_suporta_history_em_dict_do_contas",
|
||||||
|
"tests/migration/test_guardrail_original_defaults_parity.py::test_framework_loader_instancia_coer_e_preserva_fraseologia_tim_desabilitada",
|
||||||
"tests/migration/test_guardrail_original_defaults_parity.py::test_original_default_guardrails_estao_ativos_no_pipeline_framework",
|
"tests/migration/test_guardrail_original_defaults_parity.py::test_original_default_guardrails_estao_ativos_no_pipeline_framework",
|
||||||
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_billing_analysis_falha_emite_cvn007_sem_perder_complete_invoices",
|
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_billing_analysis_falha_emite_cvn007_sem_perder_complete_invoices",
|
||||||
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_cache_hit_expoe_cache_age_sem_republicar_eventos",
|
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_cache_hit_expoe_cache_age_sem_republicar_eventos",
|
||||||
@@ -250,10 +205,31 @@
|
|||||||
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_singleflight_mesma_sessao_nao_duplica_chamadas",
|
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_singleflight_mesma_sessao_nao_duplica_chamadas",
|
||||||
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_use_cache_false_forca_nova_busca",
|
"tests/migration/test_invoice_context_framework_native.py::test_invoice_context_use_cache_false_forca_nova_busca",
|
||||||
"tests/migration/test_invoice_context_framework_native.py::test_invoice_explanation_reusa_evidencia_prefetch_sem_nova_api",
|
"tests/migration/test_invoice_context_framework_native.py::test_invoice_explanation_reusa_evidencia_prefetch_sem_nova_api",
|
||||||
|
"tests/migration/test_mcp_contestation_subject_guard.py::test_cancel_preflight_exact_plan_is_out_of_scope_and_never_rebound_to_vas",
|
||||||
"tests/migration/test_mcp_contestation_subject_guard.py::test_correct_subject_is_preserved",
|
"tests/migration/test_mcp_contestation_subject_guard.py::test_correct_subject_is_preserved",
|
||||||
|
"tests/migration/test_mcp_contestation_subject_guard.py::test_invoice_resolver_exact_plan_identity_wins_before_similarity_matcher",
|
||||||
"tests/migration/test_mcp_contestation_subject_guard.py::test_preflight_does_not_replace_explicit_plan_with_vas",
|
"tests/migration/test_mcp_contestation_subject_guard.py::test_preflight_does_not_replace_explicit_plan_with_vas",
|
||||||
"tests/migration/test_mcp_contestation_subject_guard.py::test_recovers_explicit_contestation_subject_from_original_text",
|
"tests/migration/test_mcp_contestation_subject_guard.py::test_recovers_explicit_contestation_subject_from_original_text",
|
||||||
"tests/migration/test_no_legacy_dependency.py::test_no_legacy_imports_or_references",
|
"tests/migration/test_no_legacy_dependency.py::test_no_legacy_imports_or_references",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_agent_observer_maps_before_analytics_and_event_bus",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_contas_telemetry_observer_returns_mapped_envelope",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_generation_observation_name_is_mapped_before_langfuse_and_event_bus",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_langfuse_analytics_direct_sdk_path_also_maps_observation_name",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_mapper_passthrough_and_contract_fields",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_span_name_is_mapped_before_otel_event_bus_and_langfuse",
|
||||||
|
"tests/migration/test_observability_code_mapping.py::test_telemetry_maps_direct_events",
|
||||||
|
"tests/migration/test_observability_default_overlay_compatibility.py::test_agent_overlay_can_override_default_action",
|
||||||
|
"tests/migration/test_observability_default_overlay_compatibility.py::test_agent_overlay_overrides_only_declared_entries",
|
||||||
|
"tests/migration/test_observability_default_overlay_compatibility.py::test_framework_default_carries_legacy_phraseology_rewrite_policy",
|
||||||
|
"tests/migration/test_observability_default_overlay_compatibility.py::test_old_agent_without_mapping_gets_framework_default_registry",
|
||||||
|
"tests/migration/test_observability_default_overlay_compatibility.py::test_old_legacy_rail_keeps_retry_with_new_framework_and_no_agent_mapping",
|
||||||
|
"tests/migration/test_observability_mapping_provider_boundary.py::test_mapping_path_can_resolve_from_python_import_root",
|
||||||
|
"tests/migration/test_observability_mapping_provider_boundary.py::test_provider_maps_guardrail_name_before_telemetry",
|
||||||
|
"tests/migration/test_observability_mapping_registry_actions.py::test_action_resolution_accepts_internal_and_external_aliases",
|
||||||
|
"tests/migration/test_observability_mapping_registry_actions.py::test_compact_and_rich_mapping_are_backward_compatible",
|
||||||
|
"tests/migration/test_observability_mapping_registry_actions.py::test_guardrails_yaml_policy_precedes_registry_action",
|
||||||
|
"tests/migration/test_observability_mapping_registry_actions.py::test_output_supervisor_uses_registry_action_without_name_hardcode",
|
||||||
|
"tests/migration/test_observability_mapping_registry_actions.py::test_parallel_executor_uses_same_registry",
|
||||||
"tests/migration/test_original_compliance_anatel.py::test_fluxo_ajuste_com_protocolo_passa",
|
"tests/migration/test_original_compliance_anatel.py::test_fluxo_ajuste_com_protocolo_passa",
|
||||||
"tests/migration/test_original_compliance_anatel.py::test_fluxo_ajuste_sem_protocolo_bloqueia",
|
"tests/migration/test_original_compliance_anatel.py::test_fluxo_ajuste_sem_protocolo_bloqueia",
|
||||||
"tests/migration/test_original_compliance_anatel.py::test_fluxo_nao_ajuste_passa_sem_verificar",
|
"tests/migration/test_original_compliance_anatel.py::test_fluxo_nao_ajuste_passa_sem_verificar",
|
||||||
@@ -660,6 +636,9 @@
|
|||||||
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_bundle_puro_nao_reforca_e_registra_bundle]",
|
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_bundle_puro_nao_reforca_e_registra_bundle]",
|
||||||
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_estrategico_nao_com_estrategico_explica_cancelamento]",
|
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_estrategico_nao_com_estrategico_explica_cancelamento]",
|
||||||
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_estrategico_sim_finaliza_sem_protocolo_imediato]",
|
"tests/migration/test_original_workflow_cases.py::test_original_workflow_case_on_framework_runtime[vas_estrategico_sim_finaliza_sem_protocolo_imediato]",
|
||||||
|
"tests/migration/test_output_supervisor_no_contract_hardcodes.py::test_output_supervisor_contains_no_customer_grl_taxonomy_or_named_policy_switches",
|
||||||
|
"tests/migration/test_output_supervisor_no_contract_hardcodes.py::test_retry_is_driven_by_metadata_not_rail_name",
|
||||||
|
"tests/migration/test_output_supervisor_no_contract_hardcodes.py::test_rewrite_is_driven_by_remediation_metadata",
|
||||||
"tests/migration/test_pro_rata_full_parity.py::test_action_contestacao_pro_rata_usa_items_e_totais",
|
"tests/migration/test_pro_rata_full_parity.py::test_action_contestacao_pro_rata_usa_items_e_totais",
|
||||||
"tests/migration/test_pro_rata_full_parity.py::test_action_definir_devolucao_busca_danfe_quando_ausente",
|
"tests/migration/test_pro_rata_full_parity.py::test_action_definir_devolucao_busca_danfe_quando_ausente",
|
||||||
"tests/migration/test_pro_rata_full_parity.py::test_calculo_falha_quando_danfe_nao_cobre_credito",
|
"tests/migration/test_pro_rata_full_parity.py::test_calculo_falha_quando_danfe_nao_cobre_credito",
|
||||||
@@ -671,6 +650,13 @@
|
|||||||
"tests/migration/test_rag_resilience_and_informational_context.py::test_framework_rag_falha_tecnica_nao_quebra_fluxo",
|
"tests/migration/test_rag_resilience_and_informational_context.py::test_framework_rag_falha_tecnica_nao_quebra_fluxo",
|
||||||
"tests/migration/test_rag_resilience_and_informational_context.py::test_informational_rag_avulso_preserva_contexto_para_finalizacao",
|
"tests/migration/test_rag_resilience_and_informational_context.py::test_informational_rag_avulso_preserva_contexto_para_finalizacao",
|
||||||
"tests/migration/test_rag_resilience_and_informational_context.py::test_informational_rag_estrategico_sem_invoice_detail",
|
"tests/migration/test_rag_resilience_and_informational_context.py::test_informational_rag_estrategico_sem_invoice_detail",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_cancelamento_accepts_original_cancellation_aliases",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_divergencia_preserves_original_user_password_basic_auth",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_profile_bill_uses_complete_invoices_contract_like_original_factory",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_profile_full_appends_msisdn_when_original_url_has_no_placeholder",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_query_vas_accepts_original_aliases_and_timeout",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_query_vas_legacy_base_url_appends_country_code_and_msisdn",
|
||||||
|
"tests/migration/test_real_legacy_integration_compat.py::test_vas_history_and_contrato_default_client_id_match_original",
|
||||||
"tests/migration/test_remaining_command_contracts.py::test_cancelamento_always_sends_non_empty_message_id",
|
"tests/migration/test_remaining_command_contracts.py::test_cancelamento_always_sends_non_empty_message_id",
|
||||||
"tests/migration/test_remaining_command_contracts.py::test_contrato_uses_clientId_not_client_id",
|
"tests/migration/test_remaining_command_contracts.py::test_contrato_uses_clientId_not_client_id",
|
||||||
"tests/migration/test_remaining_command_contracts.py::test_profile_full_uses_original_ClientID_header",
|
"tests/migration/test_remaining_command_contracts.py::test_profile_full_uses_original_ClientID_header",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Correção aOfertas + Contas — 2026-08-21
|
|
||||||
|
|
||||||
## Problema
|
|
||||||
O guardrail AOFERTA podia receber uma confirmação transacional isolada, por exemplo
|
|
||||||
`Você confirma cancelar o TIM Music?`, sem o pedido do cliente no contexto. Com isso,
|
|
||||||
um cancelamento legitimamente solicitado podia ser interpretado como oferta proativa.
|
|
||||||
|
|
||||||
Além disso, o histórico do Contas usa principalmente `dict` (`role`/`content`), enquanto
|
|
||||||
o formatador de contexto do guardrail tratava apenas objetos de mensagem LangChain.
|
|
||||||
|
|
||||||
## Correções aplicadas
|
|
||||||
|
|
||||||
1. `app/workflows/agent_graph.py`
|
|
||||||
- `_output_guardrail_context()` passa `conversation_history` aos guardrails de saída.
|
|
||||||
- inclui `user_text` corrente quando ainda não está no histórico;
|
|
||||||
- evita duplicação da última mensagem do cliente;
|
|
||||||
- mantém `history_texts` para compatibilidade.
|
|
||||||
|
|
||||||
2. `agent_framework_oci/.../prompts/_context.py`
|
|
||||||
- histórico em `dict` (`role`, `content`) passa a ser formatado corretamente;
|
|
||||||
- preserva suporte às mensagens LangChain existentes.
|
|
||||||
|
|
||||||
3. `agent_framework_oci/.../prompts/ausencia_oferta_proativa.py`
|
|
||||||
- explicita que uma confirmação da mesma transação já solicitada pelo cliente é permitida;
|
|
||||||
- não exige que a confirmação repita a justificativa da solicitação;
|
|
||||||
- marcadores de oferta adicional continuam bloqueados.
|
|
||||||
|
|
||||||
4. `config/guardrails.yaml`
|
|
||||||
- `AOFERTA` foi reativado, pois estava comentado na versão recebida.
|
|
||||||
|
|
||||||
## Validação
|
|
||||||
Os testes específicos da correção passaram: 47/47.
|
|
||||||
|
|
||||||
O teste global de paridade ainda aponta `FRASEOLOGIA` desabilitado em
|
|
||||||
`config/guardrails.yaml`. Essa configuração já veio assim no pacote recebido e não foi
|
|
||||||
alterada por esta correção, pois está fora do escopo aOfertas/Contas.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Correção da confirmação transacional x FRASEOLOGIA — 2026-08-21
|
|
||||||
|
|
||||||
## Sintoma observado
|
|
||||||
|
|
||||||
Para a entrada `quero cancelar o serviço TIM Fashion`, o fluxo entrava corretamente em `AWAITING_CONFIRMATION`, e o guardrail `AOFERTA` permitia a resposta. Porém a confirmação determinística era montada a partir do nome técnico da tool:
|
|
||||||
|
|
||||||
`Você confirma cancelar vas avulso?`
|
|
||||||
|
|
||||||
O guardrail `FRASEOLOGIA` bloqueava essa saída porque `vas avulso` é nomenclatura interna.
|
|
||||||
|
|
||||||
## Causa raiz
|
|
||||||
|
|
||||||
`AgentRuntimeMixin.transaction_confirmation_message()` convertia o nome da tool com `tool_name.replace("_", " ")` quando não havia label explícito. Para `cancelar_vas_avulso`, isso vazava o identificador técnico para a resposta ao cliente.
|
|
||||||
|
|
||||||
## Correção
|
|
||||||
|
|
||||||
Quando a tool representa cancelamento (`cancelar_*`) e os argumentos pendentes possuem `subject`, a confirmação passa a ser construída com o nome comercial do item:
|
|
||||||
|
|
||||||
`Você confirma o cancelamento do serviço {subject}? Responda 'sim' para executar ou 'não' para cancelar.`
|
|
||||||
|
|
||||||
Exemplo:
|
|
||||||
|
|
||||||
`Você confirma o cancelamento do serviço TIM Fashion? Responda 'sim' para executar ou 'não' para cancelar.`
|
|
||||||
|
|
||||||
A correção preserva o comportamento anterior das demais confirmações, como devolução e troca de pedidos.
|
|
||||||
|
|
||||||
## Teste de regressão
|
|
||||||
|
|
||||||
Adicionado `agent_framework_oci/tests/test_transaction_confirmation_customer_facing.py`, garantindo que:
|
|
||||||
|
|
||||||
- `cancelar_vas_avulso` com `subject=TIM Fashion` nunca exponha `vas avulso` nem `cancelar_vas_avulso`;
|
|
||||||
- o nome comercial apareça na confirmação;
|
|
||||||
- confirmações existentes de varejo continuem idênticas.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Correção do loop de ContestacaoAgent
|
|
||||||
|
|
||||||
Este pacote inclui a correção do `agent_framework_oci` embarcado para permitir saída de estados transacionais quando o usuário muda explicitamente de intenção.
|
|
||||||
|
|
||||||
Também foram adicionadas keywords determinísticas no domínio Contas para:
|
|
||||||
|
|
||||||
- `quais são meus serviços`
|
|
||||||
- `quais sao meus servicos`
|
|
||||||
- `meus serviços`
|
|
||||||
- `meus servicos`
|
|
||||||
|
|
||||||
Assim, a sequência que antes ficava presa em `[ContestacaoAgent] Para prosseguir, informe subject.` passa a interromper a contestação pendente e rotear para `contas_vas_information` / `vas_agent`.
|
|
||||||
|
|
||||||
Testes focados do framework: 19 passed.
|
|
||||||
Testes de migração do Contas: 657 passed, 2 falhas preexistentes/não relacionadas em guardrails (`AOFERTA` e `FRASEOLOGIA`).
|
|
||||||
@@ -207,3 +207,8 @@ LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
|
|||||||
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
||||||
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
||||||
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
||||||
|
|
||||||
|
# Optional agent/deployment observability contract mapping.
|
||||||
|
# Keep disabled in the generic framework; agents may enable their own YAML mapping.
|
||||||
|
OBSERVABILITY_CODE_MAPPING_ENABLED=false
|
||||||
|
OBSERVABILITY_CODE_MAPPING_PATH=
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# External Guardrails / Judges
|
||||||
|
|
||||||
|
Este exemplo parte do `agent_template_backend` e demonstra a composição de componentes nativos com políticas pertencentes ao agente.
|
||||||
|
|
||||||
|
- `type: external` ativa import dinâmico somente para o componente declarado.
|
||||||
|
- Guardrail/judge síncrono roda em worker thread via `asyncio.to_thread`.
|
||||||
|
- Implementação `async` roda concorrente no event loop.
|
||||||
|
- O framework não importa `app.extensions.*` por padrão.
|
||||||
|
- Use códigos/names próprios do domínio; não sobrescreva semanticamente o genérico sem deixar a substituição explícita no YAML.
|
||||||
|
|
||||||
|
Veja também `agent_framework_oci/docs/EXTERNAL_GUARDRAILS_JUDGES.md` e `docs/EXTERNAL_GUARDRAILS_JUDGES.md` no Contas.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
###############################################################################
|
||||||
|
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
|
||||||
|
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
APP_NAME=ai-agent-template
|
||||||
|
APP_ENV=local
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LLM - OCI Generative AI como provider principal
|
||||||
|
###############################################################################
|
||||||
|
# Opções: mock, oci_openai, oci_sdk, openai_compatible
|
||||||
|
LLM_PROVIDER=oci_openai
|
||||||
|
LLM_TEMPERATURE=0.2
|
||||||
|
LLM_MAX_TOKENS=2048
|
||||||
|
LLM_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# OCI OpenAI-compatible endpoint
|
||||||
|
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
|
||||||
|
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||||
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
# OCI SDK / signer / profiles
|
||||||
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
|
OCI_PROFILE=DEFAULT
|
||||||
|
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
OCI_REGION=us-chicago-1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Persistência
|
||||||
|
###############################################################################
|
||||||
|
# Opções: memory, autonomous, mongodb
|
||||||
|
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=./data/agent_framework.db
|
||||||
|
|
||||||
|
# Autonomous Database
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=fjhsdf04954hf
|
||||||
|
ADB_DSN=oradb23aidev_high
|
||||||
|
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
|
||||||
|
ADB_WALLET_PASSWORD=fjhsdf04954hf
|
||||||
|
ADB_TABLE_PREFIX=AGENTFW
|
||||||
|
|
||||||
|
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
|
||||||
|
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
|
||||||
|
MONGODB_DATABASE=agent_platform
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
ENABLE_REDIS_CACHE=false
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# RAG / Vector / Graph
|
||||||
|
###############################################################################
|
||||||
|
VECTOR_STORE_PROVIDER=sqlite
|
||||||
|
GRAPH_STORE_PROVIDER=sqlite
|
||||||
|
RAG_TOP_K=5
|
||||||
|
EMBEDDING_PROVIDER=mock
|
||||||
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
|
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Observabilidade
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
|
||||||
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
ENABLE_OTEL=false
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Analytics / Observer corporativo
|
||||||
|
###############################################################################
|
||||||
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
|
ENABLE_ANALYTICS=false
|
||||||
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
|
AGENT_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
|
GCP_PROJECT_ID=
|
||||||
|
GCP_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TIMEOUT_SECONDS=30
|
||||||
|
# Credencial GCP segue padrão Google:
|
||||||
|
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# OCI Streaming
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_OCI_STREAMING=false
|
||||||
|
OCI_STREAM_ENDPOINT=
|
||||||
|
OCI_STREAM_OCID=
|
||||||
|
OCI_STREAM_PARTITION_KEY=agent-events
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Guardrails, Judges, Supervisor
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_INPUT_GUARDRAILS=true
|
||||||
|
ENABLE_OUTPUT_GUARDRAILS=true
|
||||||
|
ENABLE_JUDGES=true
|
||||||
|
ENABLE_SUPERVISOR=true
|
||||||
|
ENABLE_OUTPUT_SUPERVISOR=true
|
||||||
|
ENABLE_PARALLEL_GUARDRAILS=true
|
||||||
|
GUARDRAILS_FAIL_FAST=true
|
||||||
|
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||||
|
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||||
|
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||||
|
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Gateway de canais
|
||||||
|
###############################################################################
|
||||||
|
DEFAULT_CHANNEL=web
|
||||||
|
# embedded = backend may parse simple/native channel payloads.
|
||||||
|
# external = backend only accepts GatewayRequest normalized by an external Channel Gateway.
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||||
|
ENABLE_VOICE_ADAPTER=true
|
||||||
|
ENABLE_WHATSAPP_ADAPTER=true
|
||||||
|
ENABLE_TEXT_ADAPTER=true
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
# ENTERPRISE ROUTING
|
||||||
|
#################################################
|
||||||
|
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
|
||||||
|
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||||
|
# true = usa LLM para classificar quando keywords/estado não resolverem.
|
||||||
|
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
|
||||||
|
ENABLE_LLM_ROUTER=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP / Tools
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||||
|
TOOLS_CONFIG_PATH=./config/tools.yaml
|
||||||
|
TOOL_POLICIES_PATH=./config/tool_policies.yaml
|
||||||
|
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
|
||||||
|
ROUTING_MODE=router
|
||||||
|
|
||||||
|
# Usage/cost accounting
|
||||||
|
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||||
|
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||||
|
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ConversationSummaryMemory / compressão de contexto conversacional
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||||
|
MEMORY_CONTEXT_STRATEGY=summary
|
||||||
|
MEMORY_HISTORY_LIMIT=80
|
||||||
|
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||||
|
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||||
|
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||||
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
|
MEMORY_INJECT_SUMMARY=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP Gateway
|
||||||
|
###############################################################################
|
||||||
|
# true = framework routes tool calls to the dedicated MCP Gateway.
|
||||||
|
# false = framework calls MCP servers directly from mcp_servers.yaml.
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
# MCP_GATEWAY_TOKEN=
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LONG-TERM MEMORY
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LONG_TERM_MEMORY=true
|
||||||
|
LONG_TERM_MEMORY_PROVIDER=sqlite
|
||||||
|
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
|
||||||
|
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
|
||||||
|
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
|
||||||
|
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
|
||||||
|
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
|
||||||
|
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
||||||
|
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
||||||
|
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
###############################################################################
|
||||||
|
# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA
|
||||||
|
# Este arquivo é lido por Pydantic Settings no framework e no backend template.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
APP_NAME=ai-agent-template
|
||||||
|
APP_ENV=local
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LLM - OCI Generative AI como provider principal
|
||||||
|
###############################################################################
|
||||||
|
# Opções: mock, oci_openai, oci_sdk, openai_compatible
|
||||||
|
LLM_PROVIDER=oci_openai
|
||||||
|
LLM_TEMPERATURE=0.2
|
||||||
|
LLM_MAX_TOKENS=2048
|
||||||
|
LLM_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# OCI OpenAI-compatible endpoint
|
||||||
|
OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1
|
||||||
|
OCI_GENAI_MODEL=openai.gpt-4.1
|
||||||
|
OCI_GENAI_API_KEY=sk-ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6ph3FgX6
|
||||||
|
OCI_GENAI_PROJECT_OCID=
|
||||||
|
|
||||||
|
# OCI SDK / signer / profiles
|
||||||
|
OCI_CONFIG_FILE=~/.oci/config
|
||||||
|
OCI_PROFILE=DEFAULT
|
||||||
|
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
OCI_REGION=us-chicago-1
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Persistência
|
||||||
|
###############################################################################
|
||||||
|
# Opções: memory, autonomous, mongodb
|
||||||
|
SESSION_REPOSITORY_PROVIDER=sqlite
|
||||||
|
MEMORY_REPOSITORY_PROVIDER=sqlite
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=./data/agent_framework.db
|
||||||
|
|
||||||
|
# Autonomous Database
|
||||||
|
ADB_USER=admin
|
||||||
|
ADB_PASSWORD=fjhsdf04954hf
|
||||||
|
ADB_DSN=oradb23aidev_high
|
||||||
|
ADB_WALLET_LOCATION=/ORACLE/DEFAULT/Wallet_ORADB23aiDev
|
||||||
|
ADB_WALLET_PASSWORD=fjhsdf04954hf
|
||||||
|
ADB_TABLE_PREFIX=AGENTFW
|
||||||
|
|
||||||
|
# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente
|
||||||
|
MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017
|
||||||
|
MONGODB_DATABASE=agent_platform
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
ENABLE_REDIS_CACHE=false
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# RAG / Vector / Graph
|
||||||
|
###############################################################################
|
||||||
|
VECTOR_STORE_PROVIDER=sqlite
|
||||||
|
GRAPH_STORE_PROVIDER=sqlite
|
||||||
|
RAG_TOP_K=5
|
||||||
|
EMBEDDING_PROVIDER=mock
|
||||||
|
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||||
|
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Observabilidade
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LANGFUSE=true
|
||||||
|
LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact
|
||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944
|
||||||
|
LANGFUSE_HOST=http://localhost:3005
|
||||||
|
ENABLE_OTEL=false
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
OTEL_SERVICE_NAME=ai-agent-template
|
||||||
|
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Analytics / Observer corporativo
|
||||||
|
###############################################################################
|
||||||
|
# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo.
|
||||||
|
ENABLE_ANALYTICS=false
|
||||||
|
# Providers aceitos: oci_streaming,pubsub,noop
|
||||||
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
|
# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente.
|
||||||
|
AGENT_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TOPIC_PATH=
|
||||||
|
GCP_PROJECT_ID=
|
||||||
|
GCP_PUBSUB_TOPIC=
|
||||||
|
GCP_PUBSUB_TIMEOUT_SECONDS=30
|
||||||
|
# Credencial GCP segue padrão Google:
|
||||||
|
# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# OCI Streaming
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_OCI_STREAMING=false
|
||||||
|
OCI_STREAM_ENDPOINT=
|
||||||
|
OCI_STREAM_OCID=
|
||||||
|
OCI_STREAM_PARTITION_KEY=agent-events
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Guardrails, Judges, Supervisor
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_INPUT_GUARDRAILS=true
|
||||||
|
ENABLE_OUTPUT_GUARDRAILS=true
|
||||||
|
ENABLE_JUDGES=true
|
||||||
|
ENABLE_SUPERVISOR=true
|
||||||
|
ENABLE_OUTPUT_SUPERVISOR=true
|
||||||
|
ENABLE_PARALLEL_GUARDRAILS=true
|
||||||
|
GUARDRAILS_FAIL_FAST=true
|
||||||
|
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||||
|
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||||
|
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||||
|
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Gateway de canais
|
||||||
|
###############################################################################
|
||||||
|
DEFAULT_CHANNEL=web
|
||||||
|
# embedded = backend may parse simple/native channel payloads.
|
||||||
|
# external = backend only accepts GatewayRequest normalized by an external Channel Gateway.
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||||
|
ENABLE_VOICE_ADAPTER=true
|
||||||
|
ENABLE_WHATSAPP_ADAPTER=true
|
||||||
|
ENABLE_TEXT_ADAPTER=true
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
# ENTERPRISE ROUTING
|
||||||
|
#################################################
|
||||||
|
# Arquivo YAML com intents, keywords, políticas de estado e fallback.
|
||||||
|
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||||
|
# true = usa LLM para classificar quando keywords/estado não resolverem.
|
||||||
|
# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência.
|
||||||
|
ENABLE_LLM_ROUTER=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP / Tools
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_MCP_TOOLS=true
|
||||||
|
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||||
|
TOOLS_CONFIG_PATH=./config/tools.yaml
|
||||||
|
TOOL_POLICIES_PATH=./config/tool_policies.yaml
|
||||||
|
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes
|
||||||
|
ROUTING_MODE=router
|
||||||
|
|
||||||
|
# Usage/cost accounting
|
||||||
|
USAGE_REPOSITORY_PROVIDER=sqlite
|
||||||
|
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||||
|
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ConversationSummaryMemory / compressão de contexto conversacional
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||||
|
MEMORY_CONTEXT_STRATEGY=summary
|
||||||
|
MEMORY_HISTORY_LIMIT=80
|
||||||
|
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||||
|
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||||
|
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||||
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
|
MEMORY_INJECT_SUMMARY=true
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP Gateway
|
||||||
|
###############################################################################
|
||||||
|
# true = framework routes tool calls to the dedicated MCP Gateway.
|
||||||
|
# false = framework calls MCP servers directly from mcp_servers.yaml.
|
||||||
|
MCP_GATEWAY_ENABLED=true
|
||||||
|
MCP_GATEWAY_URL=http://localhost:8300
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS=60
|
||||||
|
# MCP_GATEWAY_TOKEN=
|
||||||
|
MCP_GATEWAY_AGENT_ID=telecom_contas
|
||||||
|
MCP_GATEWAY_TENANT_ID=default
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LONG-TERM MEMORY
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_LONG_TERM_MEMORY=true
|
||||||
|
LONG_TERM_MEMORY_PROVIDER=sqlite
|
||||||
|
LONG_TERM_MEMORY_SQLITE_PATH=./data/agent_framework.db
|
||||||
|
LONG_TERM_MEMORY_TABLE=agentfw_long_term_memory
|
||||||
|
# For Autonomous/Oracle, defaults to ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY
|
||||||
|
# LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
|
||||||
|
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20
|
||||||
|
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70
|
||||||
|
LONG_TERM_MEMORY_AUTO_EXTRACT=true
|
||||||
|
LONG_TERM_MEMORY_INJECT_CONTEXT=true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY agent_framework /agent_framework
|
||||||
|
COPY agent_template_backend /app
|
||||||
|
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
# Agent Template Backend Enterprise
|
||||||
|
|
||||||
|
Este folder é uma cópia completa do `agent_template_backend`, sem cortes de
|
||||||
|
arquitetura. Ele mantém workflow, router, output supervisor, guardrails,
|
||||||
|
analytics, observer, MCP, memória, checkpoints e configurações.
|
||||||
|
|
||||||
|
A diferença é que a lógica de negócio dos agentes de exemplo foi removida da
|
||||||
|
execução e preservada comentada nos próprios arquivos:
|
||||||
|
|
||||||
|
- `app/agents/billing_agent.py`
|
||||||
|
- `app/agents/product_agent.py`
|
||||||
|
- `app/agents/orders_agent.py`
|
||||||
|
- `app/agents/support_agent.py`
|
||||||
|
|
||||||
|
## O que o desenvolvedor deve alterar
|
||||||
|
|
||||||
|
1. Escolher ou criar um agente em `app/agents/`.
|
||||||
|
2. Implementar o método `run()`.
|
||||||
|
3. Ajustar prompts e tools, se necessário.
|
||||||
|
4. Emitir ICs de negócio relevantes para a jornada.
|
||||||
|
5. Manter NOC/GRL nos pontos operacionais e de guardrails.
|
||||||
|
|
||||||
|
## O que já está integrado
|
||||||
|
|
||||||
|
- `AgentObserver`
|
||||||
|
- `observer.emit_ic()`
|
||||||
|
- `observer.emit_noc()`
|
||||||
|
- `observer.emit_grl()`
|
||||||
|
- `AnalyticsPublisher`
|
||||||
|
- OCI Streaming
|
||||||
|
- GCP Pub/Sub
|
||||||
|
- OutputSupervisor
|
||||||
|
- GuardrailPipeline com suporte a execução paralela/fail-fast no framework
|
||||||
|
- MCP Tool Router
|
||||||
|
- LangGraph
|
||||||
|
- Memory
|
||||||
|
- Checkpoint
|
||||||
|
- Langfuse / OpenTelemetry
|
||||||
|
|
||||||
|
## Exemplos adicionados
|
||||||
|
|
||||||
|
Veja `app/examples/`:
|
||||||
|
|
||||||
|
- `ic_examples.py`
|
||||||
|
- `noc_examples.py`
|
||||||
|
- `grl_examples.py`
|
||||||
|
- `mcp_examples.py`
|
||||||
|
- `observer_examples.py`
|
||||||
|
|
||||||
|
## Convenção rápida
|
||||||
|
|
||||||
|
- IC = evento de negócio / curadoria / informacional.
|
||||||
|
- NOC = evento operacional / saúde técnica.
|
||||||
|
- GRL = evento de guardrail / segurança / validação.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Agentes do Template Backend Enterprise
|
||||||
|
|
||||||
|
Os arquivos desta pasta preservam a estrutura real esperada pelo workflow, mas
|
||||||
|
não executam lógica de negócio pronta.
|
||||||
|
|
||||||
|
Cada agente mostra:
|
||||||
|
|
||||||
|
- como emitir IC;
|
||||||
|
- como emitir NOC;
|
||||||
|
- como emitir GRL;
|
||||||
|
- como coletar MCP via `_collect_tool_context()`;
|
||||||
|
- como recuperar RAG via `_retrieve_rag_context()`;
|
||||||
|
- onde chamar LLM/cache.
|
||||||
|
|
||||||
|
A implementação original do exemplo está comentada no fim de cada arquivo.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from app.agents.prompting import apply_agent_profile_prompt
|
||||||
|
from app.agents.runtime import AgentRuntimeMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAgent(AgentRuntimeMixin):
|
||||||
|
name = "billingAgent"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm,
|
||||||
|
telemetry=None,
|
||||||
|
tool_router=None,
|
||||||
|
rag_service=None,
|
||||||
|
cache=None,
|
||||||
|
settings=None,
|
||||||
|
observer=None,
|
||||||
|
memory=None,
|
||||||
|
summary_memory=None,
|
||||||
|
):
|
||||||
|
self.llm = llm
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.tool_router = tool_router
|
||||||
|
self.rag_service = rag_service
|
||||||
|
self.cache = cache
|
||||||
|
self.settings = settings
|
||||||
|
self.observer = observer
|
||||||
|
self.memory = memory
|
||||||
|
self.summary_memory = summary_memory
|
||||||
|
|
||||||
|
async def run(self, state):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.BILLING_AGENT_STARTED",
|
||||||
|
state,
|
||||||
|
{"business_component": "faturas"},
|
||||||
|
component="agent.billing.start",
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_context = await self._collect_tool_context(state)
|
||||||
|
if tool_context:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.BILLING_MCP_CONTEXT_COLLECTED",
|
||||||
|
state,
|
||||||
|
{"tool_result_count": len(tool_context)},
|
||||||
|
component="agent.billing.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
state["mcp_results"] = tool_context
|
||||||
|
clarification_message = self.transaction_clarification_message(state)
|
||||||
|
if clarification_message:
|
||||||
|
return {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {clarification_message}",
|
||||||
|
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmation_message = self.transaction_confirmation_message(state)
|
||||||
|
if confirmation_message:
|
||||||
|
result = {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
|
||||||
|
"next_state": state.get("next_state"),
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="BillingAgent")
|
||||||
|
if direct_answer:
|
||||||
|
return {
|
||||||
|
"answer": direct_answer,
|
||||||
|
"next_state": state.get("next_state") or "ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
rag_context, rag_metadata = await self._retrieve_rag_context(state)
|
||||||
|
if rag_metadata.get("enabled"):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.BILLING_RAG_CONTEXT_RETRIEVED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"document_count": rag_metadata.get("document_count"),
|
||||||
|
"graph_neighbors": rag_metadata.get("graph_neighbors"),
|
||||||
|
"latency_ms": rag_metadata.get("latency_ms"),
|
||||||
|
},
|
||||||
|
component="agent.billing.rag",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepara ConversationSummaryMemory antes de montar o prompt.
|
||||||
|
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
|
||||||
|
await self.prepare_memory_context(state)
|
||||||
|
|
||||||
|
messages = self.build_messages(
|
||||||
|
state,
|
||||||
|
system_prompt=apply_agent_profile_prompt(
|
||||||
|
state,
|
||||||
|
"Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.",
|
||||||
|
),
|
||||||
|
mcp_results=tool_context,
|
||||||
|
rag_context=rag_context,
|
||||||
|
rag_metadata=rag_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = await self._invoke_llm_cached(state, "BillingAgent", messages)
|
||||||
|
result = {
|
||||||
|
"answer": f"[BillingAgent] {answer}",
|
||||||
|
"next_state": "BILLING_ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": rag_metadata,
|
||||||
|
"memory_context_metadata": state.get("memory_context_metadata"),
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.BILLING_AGENT_COMPLETED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"answer_chars": len(result.get("answer") or ""),
|
||||||
|
"has_mcp_results": bool(tool_context),
|
||||||
|
"rag_enabled": bool(rag_metadata.get("enabled")),
|
||||||
|
"memory_context": state.get("memory_context_metadata"),
|
||||||
|
},
|
||||||
|
component="agent.billing.completed",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _collect_tool_context(self, state):
|
||||||
|
return await self._collect_mcp_context(state)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from app.agents.prompting import apply_agent_profile_prompt
|
||||||
|
from app.agents.runtime import AgentRuntimeMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OrdersAgent(AgentRuntimeMixin):
|
||||||
|
name = "orders_agent"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm,
|
||||||
|
telemetry=None,
|
||||||
|
tool_router=None,
|
||||||
|
rag_service=None,
|
||||||
|
cache=None,
|
||||||
|
settings=None,
|
||||||
|
observer=None,
|
||||||
|
memory=None,
|
||||||
|
summary_memory=None,
|
||||||
|
):
|
||||||
|
self.llm = llm
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.tool_router = tool_router
|
||||||
|
self.rag_service = rag_service
|
||||||
|
self.cache = cache
|
||||||
|
self.settings = settings
|
||||||
|
self.observer = observer
|
||||||
|
self.memory = memory
|
||||||
|
self.summary_memory = summary_memory
|
||||||
|
|
||||||
|
async def run(self, state):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.ORDERS_AGENT_STARTED",
|
||||||
|
state,
|
||||||
|
{"business_component": "pedidos"},
|
||||||
|
component="agent.orders.start",
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_context = await self._collect_tool_context(state)
|
||||||
|
if tool_context:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.ORDERS_MCP_CONTEXT_COLLECTED",
|
||||||
|
state,
|
||||||
|
{"tool_result_count": len(tool_context)},
|
||||||
|
component="agent.orders.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
state["mcp_results"] = tool_context
|
||||||
|
clarification_message = self.transaction_clarification_message(state)
|
||||||
|
if clarification_message:
|
||||||
|
return {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {clarification_message}",
|
||||||
|
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmation_message = self.transaction_confirmation_message(state)
|
||||||
|
if confirmation_message:
|
||||||
|
result = {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
|
||||||
|
"next_state": state.get("next_state"),
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="OrdersAgent")
|
||||||
|
if direct_answer:
|
||||||
|
return {
|
||||||
|
"answer": direct_answer,
|
||||||
|
"next_state": state.get("next_state") or "ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
rag_context, rag_metadata = await self._retrieve_rag_context(state)
|
||||||
|
if rag_metadata.get("enabled"):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.ORDERS_RAG_CONTEXT_RETRIEVED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"document_count": rag_metadata.get("document_count"),
|
||||||
|
"graph_neighbors": rag_metadata.get("graph_neighbors"),
|
||||||
|
"latency_ms": rag_metadata.get("latency_ms"),
|
||||||
|
},
|
||||||
|
component="agent.orders.rag",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepara ConversationSummaryMemory antes de montar o prompt.
|
||||||
|
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
|
||||||
|
await self.prepare_memory_context(state)
|
||||||
|
|
||||||
|
messages = self.build_messages(
|
||||||
|
state,
|
||||||
|
system_prompt=apply_agent_profile_prompt(
|
||||||
|
state,
|
||||||
|
"Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.",
|
||||||
|
),
|
||||||
|
mcp_results=tool_context,
|
||||||
|
rag_context=rag_context,
|
||||||
|
rag_metadata=rag_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = await self._invoke_llm_cached(state, "OrdersAgent", messages)
|
||||||
|
result = {
|
||||||
|
"answer": f"[OrdersAgent] {answer}",
|
||||||
|
"next_state": "ORDER_ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": rag_metadata,
|
||||||
|
"memory_context_metadata": state.get("memory_context_metadata"),
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.ORDERS_AGENT_COMPLETED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"answer_chars": len(result.get("answer") or ""),
|
||||||
|
"has_mcp_results": bool(tool_context),
|
||||||
|
"rag_enabled": bool(rag_metadata.get("enabled")),
|
||||||
|
"memory_context": state.get("memory_context_metadata"),
|
||||||
|
},
|
||||||
|
component="agent.orders.completed",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _collect_tool_context(self, state):
|
||||||
|
return await self._collect_mcp_context(state)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from app.agents.prompting import apply_agent_profile_prompt
|
||||||
|
from app.agents.runtime import AgentRuntimeMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ProductAgent(AgentRuntimeMixin):
|
||||||
|
name = "productAgent"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm,
|
||||||
|
telemetry=None,
|
||||||
|
tool_router=None,
|
||||||
|
rag_service=None,
|
||||||
|
cache=None,
|
||||||
|
settings=None,
|
||||||
|
observer=None,
|
||||||
|
memory=None,
|
||||||
|
summary_memory=None,
|
||||||
|
):
|
||||||
|
self.llm = llm
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.tool_router = tool_router
|
||||||
|
self.rag_service = rag_service
|
||||||
|
self.cache = cache
|
||||||
|
self.settings = settings
|
||||||
|
self.observer = observer
|
||||||
|
self.memory = memory
|
||||||
|
self.summary_memory = summary_memory
|
||||||
|
|
||||||
|
async def run(self, state):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.PRODUCT_AGENT_STARTED",
|
||||||
|
state,
|
||||||
|
{"business_component": "produtos"},
|
||||||
|
component="agent.product.start",
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_context = await self._collect_tool_context(state)
|
||||||
|
if tool_context:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.PRODUCT_MCP_CONTEXT_COLLECTED",
|
||||||
|
state,
|
||||||
|
{"tool_result_count": len(tool_context)},
|
||||||
|
component="agent.product.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
state["mcp_results"] = tool_context
|
||||||
|
clarification_message = self.transaction_clarification_message(state)
|
||||||
|
if clarification_message:
|
||||||
|
return {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {clarification_message}",
|
||||||
|
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmation_message = self.transaction_confirmation_message(state)
|
||||||
|
if confirmation_message:
|
||||||
|
result = {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
|
||||||
|
"next_state": state.get("next_state"),
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="ProductAgent")
|
||||||
|
if direct_answer:
|
||||||
|
return {
|
||||||
|
"answer": direct_answer,
|
||||||
|
"next_state": state.get("next_state") or "ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
rag_context, rag_metadata = await self._retrieve_rag_context(state)
|
||||||
|
if rag_metadata.get("enabled"):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.PRODUCT_RAG_CONTEXT_RETRIEVED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"document_count": rag_metadata.get("document_count"),
|
||||||
|
"graph_neighbors": rag_metadata.get("graph_neighbors"),
|
||||||
|
"latency_ms": rag_metadata.get("latency_ms"),
|
||||||
|
},
|
||||||
|
component="agent.product.rag",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepara ConversationSummaryMemory antes de montar o prompt.
|
||||||
|
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
|
||||||
|
await self.prepare_memory_context(state)
|
||||||
|
|
||||||
|
messages = self.build_messages(
|
||||||
|
state,
|
||||||
|
system_prompt=apply_agent_profile_prompt(
|
||||||
|
state,
|
||||||
|
"Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.",
|
||||||
|
),
|
||||||
|
mcp_results=tool_context,
|
||||||
|
rag_context=rag_context,
|
||||||
|
rag_metadata=rag_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = await self._invoke_llm_cached(state, "ProductAgent", messages)
|
||||||
|
result = {
|
||||||
|
"answer": f"[ProductAgent] {answer}",
|
||||||
|
"next_state": "PRODUCT_ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": rag_metadata,
|
||||||
|
"memory_context_metadata": state.get("memory_context_metadata"),
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.PRODUCT_AGENT_COMPLETED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"answer_chars": len(result.get("answer") or ""),
|
||||||
|
"has_mcp_results": bool(tool_context),
|
||||||
|
"rag_enabled": bool(rag_metadata.get("enabled")),
|
||||||
|
"memory_context": state.get("memory_context_metadata"),
|
||||||
|
},
|
||||||
|
component="agent.product.completed",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _collect_tool_context(self, state):
|
||||||
|
return await self._collect_mcp_context(state)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
|
||||||
|
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
|
||||||
|
|
||||||
|
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
|
||||||
|
mantém prompts isolados sem duplicar o código dos agentes especializados.
|
||||||
|
"""
|
||||||
|
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
|
||||||
|
metadata = profile.get("metadata") or {}
|
||||||
|
prefix = (metadata.get("system_prefix") or "").strip()
|
||||||
|
if not prefix:
|
||||||
|
return default_prompt
|
||||||
|
return f"{prefix}\n\n{default_prompt}"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Compatibilidade local do template/backend.
|
||||||
|
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||||
|
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||||
|
from app.presentation import register_tool_renderers
|
||||||
|
|
||||||
|
register_tool_renderers()
|
||||||
|
|
||||||
|
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from app.agents.prompting import apply_agent_profile_prompt
|
||||||
|
from app.agents.runtime import AgentRuntimeMixin
|
||||||
|
|
||||||
|
|
||||||
|
class SupportAgent(AgentRuntimeMixin):
|
||||||
|
name = "support_agent"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm,
|
||||||
|
telemetry=None,
|
||||||
|
tool_router=None,
|
||||||
|
rag_service=None,
|
||||||
|
cache=None,
|
||||||
|
settings=None,
|
||||||
|
observer=None,
|
||||||
|
memory=None,
|
||||||
|
summary_memory=None,
|
||||||
|
):
|
||||||
|
self.llm = llm
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.tool_router = tool_router
|
||||||
|
self.rag_service = rag_service
|
||||||
|
self.cache = cache
|
||||||
|
self.settings = settings
|
||||||
|
self.observer = observer
|
||||||
|
self.memory = memory
|
||||||
|
self.summary_memory = summary_memory
|
||||||
|
|
||||||
|
async def run(self, state):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.SUPPORT_AGENT_STARTED",
|
||||||
|
state,
|
||||||
|
{"business_component": "suporte"},
|
||||||
|
component="agent.support.start",
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_context = await self._collect_tool_context(state)
|
||||||
|
if tool_context:
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.SUPPORT_MCP_CONTEXT_COLLECTED",
|
||||||
|
state,
|
||||||
|
{"tool_result_count": len(tool_context)},
|
||||||
|
component="agent.support.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
state["mcp_results"] = tool_context
|
||||||
|
clarification_message = self.transaction_clarification_message(state)
|
||||||
|
if clarification_message:
|
||||||
|
return {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {clarification_message}",
|
||||||
|
"next_state": state.get("next_state") or "COLLECTING_PARAMETERS",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmation_message = self.transaction_confirmation_message(state)
|
||||||
|
if confirmation_message:
|
||||||
|
result = {
|
||||||
|
"answer": f"[{self.__class__.__name__}] {confirmation_message}",
|
||||||
|
"next_state": state.get("next_state"),
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
direct_answer = self.build_direct_mcp_answer(state, tool_context, agent_label="SupportAgent")
|
||||||
|
if direct_answer:
|
||||||
|
return {
|
||||||
|
"answer": direct_answer,
|
||||||
|
"next_state": state.get("next_state") or "ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": {"enabled": False, "skipped": True, "reason": "direct_mcp_answer"},
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
rag_context, rag_metadata = await self._retrieve_rag_context(state)
|
||||||
|
if rag_metadata.get("enabled"):
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.SUPPORT_RAG_CONTEXT_RETRIEVED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"document_count": rag_metadata.get("document_count"),
|
||||||
|
"graph_neighbors": rag_metadata.get("graph_neighbors"),
|
||||||
|
"latency_ms": rag_metadata.get("latency_ms"),
|
||||||
|
},
|
||||||
|
component="agent.support.rag",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepara ConversationSummaryMemory antes de montar o prompt.
|
||||||
|
# O build_messages() do framework injeta resumo + últimas mensagens quando habilitado.
|
||||||
|
await self.prepare_memory_context(state)
|
||||||
|
|
||||||
|
messages = self.build_messages(
|
||||||
|
state,
|
||||||
|
system_prompt=apply_agent_profile_prompt(
|
||||||
|
state,
|
||||||
|
"Você é um agente de suporte de varejo para troca, devolução e garantia.",
|
||||||
|
),
|
||||||
|
mcp_results=tool_context,
|
||||||
|
rag_context=rag_context,
|
||||||
|
rag_metadata=rag_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = await self._invoke_llm_cached(state, "SupportAgent", messages)
|
||||||
|
result = {
|
||||||
|
"answer": f"[SupportAgent] {answer}",
|
||||||
|
"next_state": "SUPPORT_ACTIVE",
|
||||||
|
"mcp_results": tool_context,
|
||||||
|
"rag": rag_metadata,
|
||||||
|
"memory_context_metadata": state.get("memory_context_metadata"),
|
||||||
|
**self.transaction_state_patch(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._emit_ic(
|
||||||
|
"IC.SUPPORT_AGENT_COMPLETED",
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
"answer_chars": len(result.get("answer") or ""),
|
||||||
|
"has_mcp_results": bool(tool_context),
|
||||||
|
"rag_enabled": bool(rag_metadata.get("enabled")),
|
||||||
|
"memory_context": state.get("memory_context_metadata"),
|
||||||
|
},
|
||||||
|
component="agent.support.completed",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _collect_tool_context(self, state):
|
||||||
|
return await self._collect_mcp_context(state)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Exemplos de uso do template backend enterprise."""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Exemplos de GRL.
|
||||||
|
|
||||||
|
GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos
|
||||||
|
pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão
|
||||||
|
manual apenas para validações customizadas do agente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||||
|
await observer.emit_grl(
|
||||||
|
"OBSERVE",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"rail_code": rail_code,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
component="examples.grl",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||||
|
await observer.emit_grl(
|
||||||
|
"004",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"rail_code": rail_code,
|
||||||
|
"reason": reason,
|
||||||
|
"action": "block",
|
||||||
|
},
|
||||||
|
component="examples.grl",
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Exemplos de IC - Item de Controle.
|
||||||
|
|
||||||
|
ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria,
|
||||||
|
analytics, BigQuery ou qualquer publisher configurado no framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None:
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.FATURA_CONSULTADA",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
},
|
||||||
|
component="examples.ic",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None:
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.ACAO_CONCLUIDA",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"action_name": action_name,
|
||||||
|
"ok": ok,
|
||||||
|
},
|
||||||
|
component="examples.ic",
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Exemplos de MCP + IC.
|
||||||
|
|
||||||
|
O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o
|
||||||
|
padrão para chamadas explícitas ao tool_router quando necessário.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any:
|
||||||
|
session_id = state.get("conversation_key") or state.get("session_id")
|
||||||
|
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.MCP_TOOL_CALLED",
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"tool_name": tool_name,
|
||||||
|
},
|
||||||
|
component="examples.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tool_router.call(
|
||||||
|
tool_name,
|
||||||
|
payload,
|
||||||
|
business_context=(state.get("context") or {}).get("business_context") or {},
|
||||||
|
original_context=state.get("context") or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.TOOL_CALLED",
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"ok": getattr(result, "ok", None),
|
||||||
|
},
|
||||||
|
component="examples.mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Exemplos de NOC.
|
||||||
|
|
||||||
|
NOC representa telemetria operacional. O workflow do template já emite NOC.001,
|
||||||
|
NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode
|
||||||
|
emitir em pontos críticos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None:
|
||||||
|
await observer.emit_noc(
|
||||||
|
"002",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"apiUrl": api_url,
|
||||||
|
"statusCode": status_code,
|
||||||
|
"latencyMs": latency_ms,
|
||||||
|
},
|
||||||
|
component="examples.noc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None:
|
||||||
|
await observer.emit_noc(
|
||||||
|
"003",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"resourceName": resource_name,
|
||||||
|
"latencyMs": latency_ms,
|
||||||
|
},
|
||||||
|
component="examples.noc",
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Resumo prático do Observer corporativo.
|
||||||
|
|
||||||
|
Use este arquivo como cola rápida para IC, NOC e GRL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None:
|
||||||
|
session_id = state.get("conversation_key") or state.get("session_id")
|
||||||
|
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.EXEMPLO_NEGOCIO",
|
||||||
|
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||||
|
component="examples.observer",
|
||||||
|
)
|
||||||
|
|
||||||
|
await observer.emit_noc(
|
||||||
|
"EXEMPLO_OPERACIONAL",
|
||||||
|
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||||
|
component="examples.observer",
|
||||||
|
)
|
||||||
|
|
||||||
|
await observer.emit_grl(
|
||||||
|
"OBSERVE",
|
||||||
|
{"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"},
|
||||||
|
component="examples.observer",
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from agent_framework.guardrails.base import Guardrail, RailDecision
|
||||||
|
|
||||||
|
class ExternalBusinessPolicyRail(Guardrail):
|
||||||
|
code = "EXTERNAL_BUSINESS_POLICY"
|
||||||
|
stage = "output"
|
||||||
|
|
||||||
|
def evaluate(self, text, context):
|
||||||
|
# Synchronous on purpose: framework executes this method in a worker thread.
|
||||||
|
blocked = bool((context or {}).get("example_block"))
|
||||||
|
return RailDecision(code=self.code, allowed=not blocked, reason="example business policy" if blocked else "", metadata={"external": True})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from agent_framework.judges.judge import JudgeResult
|
||||||
|
|
||||||
|
class ExternalBusinessJudge:
|
||||||
|
name = "external_business_quality"
|
||||||
|
def __init__(self, threshold=0.5, **kwargs): self.threshold=float(threshold or 0.5)
|
||||||
|
def evaluate(self, question, answer, context):
|
||||||
|
score = 1.0 if answer and len(answer.strip()) >= 10 else 0.0
|
||||||
|
return JudgeResult(name=self.name, score=score, passed=score >= self.threshold, reason="example external judge", metadata={"external": True})
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from agent_framework.channels.base import ChannelResponse
|
||||||
|
from agent_framework.channels.gateway import ChannelGateway
|
||||||
|
from agent_framework.config.agent_registry import AgentProfileRegistry
|
||||||
|
from agent_framework.config.settings import settings
|
||||||
|
from agent_framework.analytics.factory import create_analytics_publisher
|
||||||
|
from agent_framework.observer import configure as configure_global_observer
|
||||||
|
from agent_framework.llm.providers import create_llm
|
||||||
|
from agent_framework.memory.message_history import create_memory
|
||||||
|
from agent_framework.memory.summary_memory import create_conversation_summary_memory
|
||||||
|
from agent_framework.mcp.tool_router import create_mcp_tool_router
|
||||||
|
from agent_framework.models.identity import AgentIdentity
|
||||||
|
from agent_framework.identity import IdentityResolver, BusinessContext
|
||||||
|
from agent_framework.models.session import ChatMessage, SessionContext
|
||||||
|
from agent_framework.observability.telemetry import Telemetry
|
||||||
|
from agent_framework.observability.context import set_observability_context, clear_observability_context
|
||||||
|
from agent_framework.repositories.session_repository import create_session_repository
|
||||||
|
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
|
||||||
|
from agent_framework.cache.cache import create_cache
|
||||||
|
from agent_framework.billing.usage_repository import create_usage_repository
|
||||||
|
from agent_framework.sse.events import SSEHub
|
||||||
|
from app.workflows.agent_graph import AgentWorkflow
|
||||||
|
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
|
||||||
|
|
||||||
|
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||||
|
logger = logging.getLogger("agent_template_backend")
|
||||||
|
|
||||||
|
app = FastAPI(title="Agent Template Backend FIRST-ready")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
telemetry = Telemetry(settings)
|
||||||
|
usage_repository = create_usage_repository(settings)
|
||||||
|
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
|
||||||
|
memory = create_memory(settings)
|
||||||
|
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
|
||||||
|
sessions = create_session_repository(settings)
|
||||||
|
checkpoints = create_checkpoint_repository(settings)
|
||||||
|
cache = create_cache(settings, telemetry=telemetry)
|
||||||
|
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
|
||||||
|
analytics = create_analytics_publisher(settings)
|
||||||
|
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
|
||||||
|
configure_global_observer({
|
||||||
|
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
|
||||||
|
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
|
||||||
|
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
|
||||||
|
})
|
||||||
|
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
|
||||||
|
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
|
||||||
|
agent_profiles = AgentProfileRegistry(settings)
|
||||||
|
sse_hub = SSEHub(settings, telemetry=telemetry)
|
||||||
|
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
|
||||||
|
|
||||||
|
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
|
||||||
|
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
|
||||||
|
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
|
||||||
|
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
|
||||||
|
logger.info("Framework channel input mode: %s", gateway.input_mode)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def observability_context_middleware(request: Request, call_next):
|
||||||
|
clear_observability_context()
|
||||||
|
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||||
|
set_observability_context(
|
||||||
|
request_id=request_id,
|
||||||
|
channel=request.headers.get("x-channel") or "http",
|
||||||
|
ura_call_id=request.headers.get("x-ura-call-id"),
|
||||||
|
)
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["x-request-id"] = request_id
|
||||||
|
await telemetry.event("http.request.completed", {
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"duration_ms": int((time.time() - started) * 1000),
|
||||||
|
}, kind="http")
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
await telemetry.event("http.request.failed", {
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"error": str(exc),
|
||||||
|
"duration_ms": int((time.time() - started) * 1000),
|
||||||
|
}, kind="http")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
clear_observability_context()
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayRequest(BaseModel):
|
||||||
|
channel: str = "web"
|
||||||
|
payload: dict
|
||||||
|
agent_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_value(payload: dict, key: str):
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
return metadata.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_workflow_id(payload: dict) -> str | None:
|
||||||
|
return (
|
||||||
|
payload.get("workflow_id")
|
||||||
|
or payload.get("workflowId")
|
||||||
|
or _metadata_value(payload, "workflow_id")
|
||||||
|
or _metadata_value(payload, "workflowId")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_root_span_name(template: str | None, values: dict) -> str:
|
||||||
|
template = template or "agent.gateway_message"
|
||||||
|
try:
|
||||||
|
return template.format(**{k: v or "unknown" for k, v in values.items()})
|
||||||
|
except Exception:
|
||||||
|
logger.warning("LANGFUSE_ROOT_SPAN_NAME inválido: %s", template)
|
||||||
|
return "agent.gateway_message"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
|
||||||
|
payload = req.payload or {}
|
||||||
|
context = dict(msg.context or {})
|
||||||
|
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
|
||||||
|
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
|
||||||
|
profile = agent_profiles.get(agent_id)
|
||||||
|
|
||||||
|
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
|
||||||
|
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
|
||||||
|
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
|
||||||
|
|
||||||
|
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
|
||||||
|
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
|
||||||
|
previous_business_context = context.get("business_context") or context.get("identity") or {}
|
||||||
|
business_context = identity_resolver.resolve(
|
||||||
|
{**payload, **context},
|
||||||
|
session_id=identity.conversation_key(),
|
||||||
|
previous=previous_business_context,
|
||||||
|
)
|
||||||
|
missing_identity_keys = identity_resolver.validate(business_context)
|
||||||
|
context.update({
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"business_keys": business_context.to_context_dict(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
"conversation_key": identity.conversation_key(),
|
||||||
|
"original_session_id": msg.session_id,
|
||||||
|
})
|
||||||
|
return identity, context, business_context, missing_identity_keys
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
|
||||||
|
try:
|
||||||
|
msg = await gateway.normalize(req.channel, req.payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
payload = req.payload or {}
|
||||||
|
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||||
|
agent_session_id = identity.conversation_key()
|
||||||
|
message_id = payload.get("message_id") or str(uuid4())
|
||||||
|
workflow_id = _extract_workflow_id(payload)
|
||||||
|
set_observability_context(
|
||||||
|
session_id=agent_session_id,
|
||||||
|
user_id=msg.user_id,
|
||||||
|
tenant_id=identity.tenant_id,
|
||||||
|
agent_id=identity.agent_id,
|
||||||
|
channel=msg.channel,
|
||||||
|
message_id=message_id,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
ura_call_id=payload.get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = sse_hub.stream_for(agent_session_id)
|
||||||
|
async with stream.lock:
|
||||||
|
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
|
||||||
|
|
||||||
|
session = await sessions.get(agent_session_id)
|
||||||
|
if not session:
|
||||||
|
context_fields = {
|
||||||
|
k: v
|
||||||
|
for k, v in normalized_context.items()
|
||||||
|
if k in SessionContext.model_fields
|
||||||
|
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
|
||||||
|
}
|
||||||
|
session = SessionContext(
|
||||||
|
tenant_id=identity.tenant_id,
|
||||||
|
agent_id=identity.agent_id,
|
||||||
|
session_id=agent_session_id,
|
||||||
|
user_id=msg.user_id,
|
||||||
|
channel=msg.channel,
|
||||||
|
channel_id=msg.channel_id,
|
||||||
|
**context_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.tenant_id = identity.tenant_id
|
||||||
|
session.agent_id = identity.agent_id
|
||||||
|
session.channel = msg.channel
|
||||||
|
session.channel_id = msg.channel_id or session.channel_id
|
||||||
|
await sessions.upsert(session)
|
||||||
|
session.metadata = {
|
||||||
|
**(session.metadata or {}),
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
"original_context": normalized_context,
|
||||||
|
}
|
||||||
|
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
|
||||||
|
|
||||||
|
await memory.append(
|
||||||
|
agent_session_id,
|
||||||
|
ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=msg.text,
|
||||||
|
metadata={
|
||||||
|
**normalized_context,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
|
||||||
|
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
|
||||||
|
|
||||||
|
cms_input = {
|
||||||
|
"channel": req.channel,
|
||||||
|
"tenant_id": req.tenant_id,
|
||||||
|
"agent_id": req.agent_id,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
trace_context = {
|
||||||
|
"text": msg.text,
|
||||||
|
"channel": msg.channel,
|
||||||
|
"channel_id": msg.channel_id,
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
}
|
||||||
|
root_span_name = _format_root_span_name(
|
||||||
|
getattr(settings, "LANGFUSE_ROOT_SPAN_NAME", "agent.gateway_message"),
|
||||||
|
{
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"channel": msg.channel,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
root_tags = ["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"]
|
||||||
|
if workflow_id:
|
||||||
|
root_tags.append(f"workflow:{workflow_id}")
|
||||||
|
|
||||||
|
async with telemetry.span(
|
||||||
|
root_span_name,
|
||||||
|
session_id=agent_session_id,
|
||||||
|
user_id=session.user_id,
|
||||||
|
channel=msg.channel,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
input=cms_input,
|
||||||
|
tags=root_tags,
|
||||||
|
_root_span=True,
|
||||||
|
) as root_span:
|
||||||
|
await telemetry.event("gateway.message.received", trace_context)
|
||||||
|
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
|
||||||
|
result = await workflow.ainvoke(
|
||||||
|
{
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"session_id": agent_session_id,
|
||||||
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"agent_profile": normalized_context["agent_profile"],
|
||||||
|
"user_text": msg.text,
|
||||||
|
"history": history,
|
||||||
|
"context": {
|
||||||
|
**normalized_context,
|
||||||
|
"session": session.model_dump(mode="json"),
|
||||||
|
"original_session_id": msg.session_id,
|
||||||
|
"session_id": agent_session_id,
|
||||||
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"user_id": session.user_id,
|
||||||
|
"channel": msg.channel,
|
||||||
|
"message_id": message_id,
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"business_keys": business_context.to_context_dict(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
|
||||||
|
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
|
||||||
|
|
||||||
|
answer = result.get("final_answer") or result.get("answer") or ""
|
||||||
|
await memory.append(
|
||||||
|
agent_session_id,
|
||||||
|
ChatMessage(
|
||||||
|
role="assistant",
|
||||||
|
content=answer,
|
||||||
|
metadata={
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"message_id": f"assistant-{message_id}",
|
||||||
|
"route": result.get("route"),
|
||||||
|
"intent": result.get("intent"),
|
||||||
|
"route_decision": result.get("route_decision"),
|
||||||
|
"judges": result.get("judge_results"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await telemetry.event(
|
||||||
|
"gateway.message.responded",
|
||||||
|
{
|
||||||
|
"session_id": agent_session_id,
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"route": result.get("route"),
|
||||||
|
"intent": result.get("intent"),
|
||||||
|
"answer_chars": len(answer),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ChannelResponse(
|
||||||
|
channel=msg.channel,
|
||||||
|
session_id=agent_session_id,
|
||||||
|
text=answer,
|
||||||
|
metadata={
|
||||||
|
"channel_id": msg.channel_id,
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"original_session_id": msg.session_id,
|
||||||
|
"conversation_key": agent_session_id,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"route": result.get("route"),
|
||||||
|
"intent": result.get("intent"),
|
||||||
|
"route_decision": result.get("route_decision"),
|
||||||
|
"domain": result.get("domain"),
|
||||||
|
"mcp_tools": result.get("mcp_tools"),
|
||||||
|
"mcp_results": result.get("mcp_results"),
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
"judges": result.get("judge_results"),
|
||||||
|
"guardrails": result.get("guardrail_decisions"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rendered = await gateway.render(response)
|
||||||
|
root_span.set_output(rendered)
|
||||||
|
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||||
|
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"llm_provider": settings.LLM_PROVIDER,
|
||||||
|
"llm_class": llm.__class__.__name__,
|
||||||
|
"langfuse_enabled": telemetry.is_enabled(),
|
||||||
|
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
|
||||||
|
"default_agent_id": agent_profiles.default_agent_id,
|
||||||
|
"routing_mode": settings.ROUTING_MODE,
|
||||||
|
"sse_enabled": settings.ENABLE_SSE,
|
||||||
|
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
|
||||||
|
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||||
|
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||||
|
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
|
||||||
|
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
|
||||||
|
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
|
||||||
|
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||||
|
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agents")
|
||||||
|
async def list_agents():
|
||||||
|
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/debug/env")
|
||||||
|
async def debug_env():
|
||||||
|
return {
|
||||||
|
"APP_ENV": settings.APP_ENV,
|
||||||
|
"LLM_PROVIDER": settings.LLM_PROVIDER,
|
||||||
|
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
|
||||||
|
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
|
||||||
|
"TELEMETRY_ENABLED": telemetry.is_enabled(),
|
||||||
|
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
|
||||||
|
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
|
||||||
|
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||||
|
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||||
|
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
|
||||||
|
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
|
||||||
|
"ROUTING_MODE": settings.ROUTING_MODE,
|
||||||
|
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||||
|
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/test-llm")
|
||||||
|
async def test_llm():
|
||||||
|
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
|
||||||
|
answer = await llm.ainvoke([
|
||||||
|
{"role": "system", "content": "Responda de forma curta."},
|
||||||
|
{"role": "user", "content": "Diga apenas OK"},
|
||||||
|
])
|
||||||
|
telemetry.flush()
|
||||||
|
return {"provider": llm.__class__.__name__, "answer": answer}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/debug/route")
|
||||||
|
async def debug_route(req: GatewayRequest):
|
||||||
|
msg = await gateway.normalize(req.channel, req.payload)
|
||||||
|
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||||
|
state = {
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"session_id": msg.session_id or "debug-session",
|
||||||
|
"conversation_key": identity.conversation_key(),
|
||||||
|
"agent_profile": context["agent_profile"],
|
||||||
|
"user_text": msg.text,
|
||||||
|
"sanitized_input": msg.text,
|
||||||
|
"history": [],
|
||||||
|
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
|
||||||
|
}
|
||||||
|
if settings.ROUTING_MODE == "supervisor":
|
||||||
|
plan = await workflow.supervisor.route_plan(state)
|
||||||
|
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
|
||||||
|
decision = await workflow.router.route(state)
|
||||||
|
data = decision.model_dump(mode="json")
|
||||||
|
data["mode"] = "router"
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/debug/identity")
|
||||||
|
async def debug_identity(req: GatewayRequest):
|
||||||
|
msg = await gateway.normalize(req.channel, req.payload)
|
||||||
|
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||||
|
return {
|
||||||
|
"technical_identity": {
|
||||||
|
"tenant_id": identity.tenant_id,
|
||||||
|
"agent_id": identity.agent_id,
|
||||||
|
"conversation_key": identity.conversation_key(),
|
||||||
|
"original_session_id": msg.session_id,
|
||||||
|
},
|
||||||
|
"business_context": business_context.model_dump(),
|
||||||
|
"identity_missing": missing_identity_keys,
|
||||||
|
"context_keys": sorted(context.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/debug/usage")
|
||||||
|
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
|
||||||
|
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/debug/mcp/tools")
|
||||||
|
async def debug_mcp_tools():
|
||||||
|
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/debug/mcp/call/{tool_name}")
|
||||||
|
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
|
||||||
|
arguments = arguments or {}
|
||||||
|
ctx = arguments.get("business_context") or arguments.get("identity") or {}
|
||||||
|
result = await tool_router.call(
|
||||||
|
tool_name,
|
||||||
|
arguments,
|
||||||
|
business_context=ctx,
|
||||||
|
original_context=arguments,
|
||||||
|
)
|
||||||
|
return result.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/gateway/message")
|
||||||
|
async def gateway_message(req: GatewayRequest):
|
||||||
|
return await _process_gateway_message(req, emit_sse=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/gateway/message/sse")
|
||||||
|
async def gateway_message_sse(req: GatewayRequest):
|
||||||
|
return await _process_gateway_message(req, emit_sse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/gateway/events/{session_id}")
|
||||||
|
async def gateway_events(session_id: str, request: Request):
|
||||||
|
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
|
||||||
|
return StreamingResponse(
|
||||||
|
sse_hub.subscribe(session_id, int(last)),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sessions/{session_id}/messages")
|
||||||
|
async def get_session_messages(session_id: str, limit: int = 50):
|
||||||
|
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sessions/{session_id}/checkpoint")
|
||||||
|
async def get_session_checkpoint(session_id: str):
|
||||||
|
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown():
|
||||||
|
telemetry.shutdown()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from agent_framework.gateways import MCPGatewayClient
|
||||||
|
|
||||||
|
|
||||||
|
def build_mcp_gateway_client() -> MCPGatewayClient | None:
|
||||||
|
if os.getenv("MCP_GATEWAY_ENABLED", "true").lower() != "true":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return MCPGatewayClient(
|
||||||
|
base_url=os.getenv("MCP_GATEWAY_URL", "http://localhost:8300"),
|
||||||
|
token=os.getenv("MCP_GATEWAY_TOKEN") or None,
|
||||||
|
timeout_seconds=int(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "60")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
|
||||||
|
|
||||||
|
This avoids a second Langfuse root trace created by AgentObserver ->
|
||||||
|
AnalyticsPublisher while preserving the events inside the active request span.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ic_code(code: str) -> str:
|
||||||
|
code = str(code or "UNKNOWN").strip()
|
||||||
|
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_noc_code(code: str) -> str:
|
||||||
|
code = str(code or "UNKNOWN").strip()
|
||||||
|
return code if code.startswith("NOC.") else f"NOC.{code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_grl_code(code: str) -> str:
|
||||||
|
code = str(code or "UNKNOWN").strip()
|
||||||
|
return code if code.startswith("GRL.") else f"GRL.{code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_for(event_type: str) -> str:
|
||||||
|
if event_type.startswith(("IC.", "AGA.")):
|
||||||
|
return "ic"
|
||||||
|
if event_type.startswith("NOC."):
|
||||||
|
return "noc"
|
||||||
|
if event_type.startswith("GRL."):
|
||||||
|
return "grl"
|
||||||
|
return "event"
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryBackedAgentObserver:
|
||||||
|
"""Drop-in subset of AgentObserver backed by Telemetry.event.
|
||||||
|
|
||||||
|
Do not publish through AnalyticsPublisher here. Analytics publishing may be
|
||||||
|
configured with a Langfuse provider, and that path creates an extra root
|
||||||
|
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
|
||||||
|
uses the active span/trace context, so these events appear inside the single
|
||||||
|
request trace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
async def emit(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = dict(payload or {})
|
||||||
|
meta = dict(metadata or {})
|
||||||
|
body.setdefault("tag", event_type)
|
||||||
|
event = {
|
||||||
|
"eventType": event_type,
|
||||||
|
"source": source or self.source,
|
||||||
|
"eventDate": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"body": body,
|
||||||
|
"metadata": meta,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
|
||||||
|
except TypeError:
|
||||||
|
# Compatibility with older Telemetry.event signatures.
|
||||||
|
await self.telemetry.event(event_type, event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||||
|
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
|
||||||
|
|
||||||
|
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||||
|
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
|
||||||
|
|
||||||
|
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||||
|
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tool_renderers import register_tool_renderers
|
||||||
|
|
||||||
|
__all__ = ["register_tool_renderers"]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.presentation import register_tool_response_renderer
|
||||||
|
|
||||||
|
|
||||||
|
def _money_brl(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):.2f}".replace(".", ",")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def render_telecom_invoice(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
return f"[{agent_label}] Fatura consultada: {result}."
|
||||||
|
|
||||||
|
|
||||||
|
def render_telecom_plan(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
plano = result.get("plano")
|
||||||
|
if plano is None:
|
||||||
|
return None
|
||||||
|
parts = [f"[{agent_label}] Seu plano é {plano}"]
|
||||||
|
internet_gb = result.get("internet_gb")
|
||||||
|
status = result.get("status")
|
||||||
|
if internet_gb is not None:
|
||||||
|
parts.append(f"com {internet_gb} GB")
|
||||||
|
if status is not None:
|
||||||
|
parts.append(f"status {status}")
|
||||||
|
return ", ".join(parts) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def render_retail_order(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
order_id = result.get("order_id")
|
||||||
|
status = result.get("status")
|
||||||
|
if order_id is None or status is None:
|
||||||
|
return None
|
||||||
|
lines = [f"[{agent_label}] Pedido {order_id}: status {status}."]
|
||||||
|
total = result.get("valor_total")
|
||||||
|
if total is not None:
|
||||||
|
lines.append(f"Valor total: R$ {_money_brl(total)}.")
|
||||||
|
items = result.get("itens") or []
|
||||||
|
rendered_items: list[str] = []
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
value = item.get("descricao") or item.get("nome") or item.get("sku")
|
||||||
|
else:
|
||||||
|
value = item
|
||||||
|
if value not in (None, ""):
|
||||||
|
rendered_items.append(str(value))
|
||||||
|
if rendered_items:
|
||||||
|
lines.append("Itens: " + "; ".join(rendered_items) + ".")
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_retail_delivery(*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str) -> str | None:
|
||||||
|
order_id = result.get("order_id")
|
||||||
|
transportadora = result.get("transportadora")
|
||||||
|
codigo = result.get("codigo_rastreio")
|
||||||
|
previsao = result.get("previsao_entrega")
|
||||||
|
if any(v is None for v in (order_id, transportadora, codigo, previsao)):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"[{agent_label}] Entrega do pedido {order_id}: transportadora {transportadora}, "
|
||||||
|
f"rastreio {codigo}, previsão {previsao}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_tool_renderers() -> None:
|
||||||
|
register_tool_response_renderer("telecom.invoice", render_telecom_invoice)
|
||||||
|
register_tool_response_renderer("telecom.plan", render_telecom_plan)
|
||||||
|
register_tool_response_renderer("retail.order", render_retail_order)
|
||||||
|
register_tool_response_renderer("retail.delivery", render_retail_delivery)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class AgentState(TypedDict, total=False):
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
session_id: str
|
||||||
|
conversation_key: str
|
||||||
|
workflow_id: str
|
||||||
|
agent_profile: dict[str, Any]
|
||||||
|
user_text: str
|
||||||
|
sanitized_input: str
|
||||||
|
route: str
|
||||||
|
intent: str
|
||||||
|
route_decision: dict[str, Any]
|
||||||
|
answer: str
|
||||||
|
final_answer: str
|
||||||
|
history: list[dict[str, Any]]
|
||||||
|
context: dict[str, Any]
|
||||||
|
guardrail_decisions: list[dict[str, Any]]
|
||||||
|
judge_results: list[dict[str, Any]]
|
||||||
|
next_state: str
|
||||||
|
domain: str
|
||||||
|
mcp_tools: list[str]
|
||||||
|
mcp_results: list[dict[str, Any]]
|
||||||
|
available_mcp_tools: list[str]
|
||||||
|
selected_tool_call: dict[str, Any]
|
||||||
|
pending_tool_call: dict[str, Any]
|
||||||
|
active_transaction: dict[str, Any]
|
||||||
|
last_transaction: dict[str, Any]
|
||||||
|
transaction_status: str
|
||||||
|
confirmation_required: bool
|
||||||
|
confirmation_received: bool
|
||||||
|
tool_policy_result: dict[str, Any]
|
||||||
|
missing_parameters: list[str]
|
||||||
|
supervisor_plan: dict[str, Any]
|
||||||
|
supervisor_results: list[dict[str, Any]]
|
||||||
|
active_agent: str
|
||||||
|
route_bypassed: bool
|
||||||
|
continuity_signal: dict[str, Any]
|
||||||
|
session_control: str
|
||||||
|
session_ended: bool
|
||||||
|
human_handoff_requested: bool
|
||||||
|
blocked: bool
|
||||||
|
supervisor_action: str
|
||||||
|
supervisor_guidance: str
|
||||||
|
supervisor_attempt: int
|
||||||
|
supervisor_handover_reason: str
|
||||||
|
output_supervisor_results: list[dict[str, Any]]
|
||||||
|
output_guardrails_already_applied: bool
|
||||||
|
long_term_memories: list[dict[str, Any]]
|
||||||
|
long_term_memory_context: str
|
||||||
|
long_term_memory_write_result: dict[str, Any]
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
|
||||||
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
|
||||||
|
from agent_framework.guardrails.pipeline import GuardrailPipeline
|
||||||
|
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||||
|
from agent_framework.guardrails.rail_action import RailAction
|
||||||
|
from agent_framework.guardrails.rail_result import RailResult
|
||||||
|
from agent_framework.judges.judge import JudgePipeline
|
||||||
|
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||||
|
from agent_framework.supervisor.supervisor import Supervisor
|
||||||
|
from agent_framework.observability.workflow_events import WorkflowTelemetry
|
||||||
|
from agent_framework.observability.guardrail_events import GuardrailTelemetry
|
||||||
|
from agent_framework.observability.judge_events import JudgeTelemetry
|
||||||
|
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
|
||||||
|
from agent_framework.observability.observer import AgentObserver
|
||||||
|
from app.agents.billing_agent import BillingAgent
|
||||||
|
from app.agents.product_agent import ProductAgent
|
||||||
|
from app.agents.orders_agent import OrdersAgent
|
||||||
|
from app.agents.support_agent import SupportAgent
|
||||||
|
from app.state import AgentState
|
||||||
|
from agent_framework.rag.rag_service import RagService
|
||||||
|
from agent_framework.rag.embedding_provider import create_embedding_provider
|
||||||
|
from agent_framework.cache.cache import create_cache
|
||||||
|
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyOutputGuardrailRail:
|
||||||
|
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
|
||||||
|
|
||||||
|
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
|
||||||
|
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
|
||||||
|
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
code = "LEGACY_OUTPUT_GUARDRAILS"
|
||||||
|
|
||||||
|
def __init__(self, pipeline: GuardrailPipeline):
|
||||||
|
self.pipeline = pipeline
|
||||||
|
|
||||||
|
async def evaluate(self, candidate: str, context: dict):
|
||||||
|
final, decisions = await self.pipeline.run_output(candidate, context)
|
||||||
|
serialized = [d.model_dump() for d in decisions]
|
||||||
|
|
||||||
|
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
|
||||||
|
if blocked:
|
||||||
|
first = blocked[0]
|
||||||
|
code = (getattr(first, "code", "") or "").upper()
|
||||||
|
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
|
||||||
|
return RailResult(
|
||||||
|
code=code or self.code,
|
||||||
|
action=action,
|
||||||
|
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
|
||||||
|
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
|
||||||
|
sanitized_text=final,
|
||||||
|
metadata={"legacy_decisions": serialized},
|
||||||
|
)
|
||||||
|
|
||||||
|
if final != candidate:
|
||||||
|
return RailResult(
|
||||||
|
code=self.code,
|
||||||
|
action=RailAction.SANITIZE,
|
||||||
|
reason="Resposta sanitizada por guardrail de saída legado.",
|
||||||
|
sanitized_text=final,
|
||||||
|
metadata={"legacy_decisions": serialized},
|
||||||
|
)
|
||||||
|
|
||||||
|
return RailResult(
|
||||||
|
code=self.code,
|
||||||
|
action=RailAction.ALLOW,
|
||||||
|
reason="Resposta aprovada pelos guardrails de saída legados.",
|
||||||
|
sanitized_text=final,
|
||||||
|
metadata={"legacy_decisions": serialized},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWorkflow:
|
||||||
|
"""Workflow principal com dois modos de roteamento.
|
||||||
|
|
||||||
|
Modos suportados por configuração:
|
||||||
|
ROUTING_MODE=router
|
||||||
|
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
|
||||||
|
|
||||||
|
ROUTING_MODE=supervisor
|
||||||
|
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
|
||||||
|
|
||||||
|
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
|
||||||
|
self.llm = llm
|
||||||
|
self.memory = memory
|
||||||
|
self.telemetry = telemetry
|
||||||
|
self.analytics = analytics
|
||||||
|
self.observer = observer or AgentObserver(analytics=analytics)
|
||||||
|
self.settings = settings
|
||||||
|
self.tool_router = tool_router
|
||||||
|
self.summary_memory = summary_memory
|
||||||
|
self.long_term_memory_manager = create_long_term_memory_manager(settings, telemetry=telemetry)
|
||||||
|
self.guardrails = GuardrailPipeline(
|
||||||
|
observer=self.observer,
|
||||||
|
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||||
|
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||||
|
)
|
||||||
|
self.output_supervisor_engine = OutputSupervisor(
|
||||||
|
rails=[LegacyOutputGuardrailRail(self.guardrails)],
|
||||||
|
observer=self.observer,
|
||||||
|
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
|
||||||
|
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||||
|
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||||
|
)
|
||||||
|
self.judges = JudgePipeline()
|
||||||
|
self.supervisor = Supervisor()
|
||||||
|
self.workflow_telemetry = WorkflowTelemetry(telemetry)
|
||||||
|
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
|
||||||
|
self.judge_telemetry = JudgeTelemetry(telemetry)
|
||||||
|
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
|
||||||
|
self.cache = create_cache(settings)
|
||||||
|
self.embedding_provider = create_embedding_provider(settings)
|
||||||
|
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry)
|
||||||
|
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
|
||||||
|
agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer, "memory": memory, "summary_memory": summary_memory}
|
||||||
|
self.billing = BillingAgent(llm, **agent_kwargs)
|
||||||
|
self.product = ProductAgent(llm, **agent_kwargs)
|
||||||
|
self.orders = OrdersAgent(llm, **agent_kwargs)
|
||||||
|
self.support = SupportAgent(llm, **agent_kwargs)
|
||||||
|
|
||||||
|
# The existing agent constructors intentionally keep their stable API.
|
||||||
|
# Long-term memory is injected as a runtime capability after creation.
|
||||||
|
for agent in (self.billing, self.product, self.orders, self.support):
|
||||||
|
agent.long_term_memory_manager = self.long_term_memory_manager
|
||||||
|
self.graph = self._build_graph()
|
||||||
|
|
||||||
|
def _node(self, name, fn):
|
||||||
|
async def _wrapped(state):
|
||||||
|
async with self.langgraph_telemetry.node(name, state):
|
||||||
|
return await fn(state)
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
def _build_graph(self):
|
||||||
|
builder = StateGraph(AgentState)
|
||||||
|
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||||
|
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
|
||||||
|
builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent))
|
||||||
|
builder.add_node("product_agent", self._node("product_agent", self.product_agent))
|
||||||
|
builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent))
|
||||||
|
builder.add_node("support_agent", self._node("support_agent", self.support_agent))
|
||||||
|
builder.add_node("handoff", self._node("handoff", self.handoff))
|
||||||
|
builder.add_node("human_handoff", self._node("human_handoff", self.human_handoff))
|
||||||
|
builder.add_node("end_session", self._node("end_session", self.end_session))
|
||||||
|
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
|
||||||
|
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
|
||||||
|
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
|
||||||
|
builder.add_node("judge", self._node("judge", self.judge))
|
||||||
|
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
|
||||||
|
builder.add_node("persist_long_term_memory", self._node("persist_long_term_memory", self.persist_long_term_memory))
|
||||||
|
builder.add_node("persist", self._node("persist", self.persist))
|
||||||
|
|
||||||
|
builder.add_edge(START, "input_guardrails")
|
||||||
|
builder.add_conditional_edges(
|
||||||
|
"input_guardrails",
|
||||||
|
self._after_input_guardrails,
|
||||||
|
{"blocked": "persist", "continue": "routing_decision"},
|
||||||
|
)
|
||||||
|
builder.add_conditional_edges(
|
||||||
|
"routing_decision",
|
||||||
|
lambda s: s.get("route", "billing_agent"),
|
||||||
|
{
|
||||||
|
"billing_agent": "billing_agent",
|
||||||
|
"product_agent": "product_agent",
|
||||||
|
"orders_agent": "orders_agent",
|
||||||
|
"support_agent": "support_agent",
|
||||||
|
"handoff": "handoff",
|
||||||
|
"human_handoff": "human_handoff",
|
||||||
|
"end_session": "end_session",
|
||||||
|
"supervisor_agent": "supervisor_agent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
builder.add_edge("billing_agent", "output_supervisor")
|
||||||
|
builder.add_edge("product_agent", "output_supervisor")
|
||||||
|
builder.add_edge("orders_agent", "output_supervisor")
|
||||||
|
builder.add_edge("support_agent", "output_supervisor")
|
||||||
|
builder.add_edge("handoff", "output_supervisor")
|
||||||
|
builder.add_edge("human_handoff", "output_supervisor")
|
||||||
|
builder.add_edge("end_session", "output_supervisor")
|
||||||
|
builder.add_edge("supervisor_agent", "output_supervisor")
|
||||||
|
builder.add_edge("output_supervisor", "output_guardrails")
|
||||||
|
builder.add_edge("output_guardrails", "judge")
|
||||||
|
builder.add_edge("judge", "supervisor_review")
|
||||||
|
builder.add_edge("supervisor_review", "persist_long_term_memory")
|
||||||
|
builder.add_edge("persist_long_term_memory", "persist")
|
||||||
|
builder.add_edge("persist", END)
|
||||||
|
|
||||||
|
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
|
||||||
|
|
||||||
|
def _after_input_guardrails(self, state):
|
||||||
|
return "blocked" if state.get("blocked") else "continue"
|
||||||
|
|
||||||
|
async def input_guardrails(self, state):
|
||||||
|
if state.get("session_ended") is True:
|
||||||
|
answer = str(getattr(
|
||||||
|
self.settings,
|
||||||
|
"SESSION_ALREADY_ENDED_MESSAGE",
|
||||||
|
"Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.",
|
||||||
|
))
|
||||||
|
await self.telemetry.event(
|
||||||
|
"session.message.rejected_after_end",
|
||||||
|
{"session_id": state.get("conversation_key") or state.get("session_id")},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"final_answer": answer,
|
||||||
|
"blocked": True,
|
||||||
|
"session_control": "END_SESSION",
|
||||||
|
"session_ended": True,
|
||||||
|
"next_state": "SESSION_ENDED",
|
||||||
|
}
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.input_guardrails",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input=state.get("user_text"),
|
||||||
|
):
|
||||||
|
history_texts = [m.get("content", "") for m in state.get("history", [])]
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"001",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "input",
|
||||||
|
},
|
||||||
|
component="workflow.input_guardrails.start",
|
||||||
|
)
|
||||||
|
sanitized, decisions = await self.guardrails.run_input(
|
||||||
|
state["user_text"],
|
||||||
|
{
|
||||||
|
**(state.get("context") or {}),
|
||||||
|
"history_texts": history_texts,
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"agent_profile": state.get("agent_profile") or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for _decision in decisions:
|
||||||
|
await self.guardrail_telemetry.evaluated("input", _decision)
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"002" if _decision.allowed else "004",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "input",
|
||||||
|
"rail_code": getattr(_decision, "code", None),
|
||||||
|
"allowed": bool(_decision.allowed),
|
||||||
|
"reason": getattr(_decision, "reason", None),
|
||||||
|
},
|
||||||
|
component="workflow.input_guardrails.decision",
|
||||||
|
)
|
||||||
|
if not _decision.allowed:
|
||||||
|
await self.guardrail_telemetry.blocked("input", _decision)
|
||||||
|
await self.telemetry.event(
|
||||||
|
"guardrails.input.completed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"decisions": [d.model_dump() for d in decisions],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"009",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "input",
|
||||||
|
"blocked": any(not d.allowed for d in decisions),
|
||||||
|
"decision_count": len(decisions),
|
||||||
|
},
|
||||||
|
component="workflow.input_guardrails.final",
|
||||||
|
)
|
||||||
|
if any(not d.allowed for d in decisions):
|
||||||
|
return {
|
||||||
|
"sanitized_input": sanitized,
|
||||||
|
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||||
|
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
|
||||||
|
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||||
|
"route": "blocked",
|
||||||
|
"blocked": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"sanitized_input": sanitized,
|
||||||
|
"guardrail_decisions": [d.model_dump() for d in decisions],
|
||||||
|
"blocked": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def routing_decision(self, state):
|
||||||
|
mode = getattr(self.settings, "ROUTING_MODE", "router")
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.routing_decision",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={
|
||||||
|
"mode": mode,
|
||||||
|
"text": state.get("sanitized_input") or state.get("user_text"),
|
||||||
|
"previous_state": state.get("next_state"),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
if mode == "supervisor":
|
||||||
|
plan = await self.supervisor.route_plan(state)
|
||||||
|
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
|
||||||
|
return {
|
||||||
|
"route": "supervisor_agent",
|
||||||
|
"intent": plan.intent,
|
||||||
|
"supervisor_plan": {
|
||||||
|
"agents": plan.agents,
|
||||||
|
"intent": plan.intent,
|
||||||
|
"confidence": plan.confidence,
|
||||||
|
"reason": plan.reason,
|
||||||
|
"metadata": plan.metadata,
|
||||||
|
},
|
||||||
|
"route_decision": {
|
||||||
|
"route": "supervisor_agent",
|
||||||
|
"agent": "supervisor",
|
||||||
|
"intent": plan.intent,
|
||||||
|
"confidence": plan.confidence,
|
||||||
|
"reason": plan.reason,
|
||||||
|
"method": "supervisor",
|
||||||
|
"metadata": plan.metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = await self.router.route(state)
|
||||||
|
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
|
||||||
|
await self.observer.emit_ic(
|
||||||
|
"ROUTE_SELECTED",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"route": decision.route,
|
||||||
|
"intent": decision.intent,
|
||||||
|
"confidence": decision.confidence,
|
||||||
|
"method": getattr(decision, "method", None),
|
||||||
|
},
|
||||||
|
component="workflow.routing_decision",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"route": decision.route,
|
||||||
|
"intent": decision.intent,
|
||||||
|
"route_decision": decision.model_dump(mode="json"),
|
||||||
|
"domain": decision.domain,
|
||||||
|
"mcp_tools": decision.mcp_tools,
|
||||||
|
"next_state": decision.next_state,
|
||||||
|
"active_agent": decision.agent,
|
||||||
|
"route_bypassed": decision.method == "continuity",
|
||||||
|
"session_control": (decision.metadata or {}).get("session_control", ""),
|
||||||
|
"human_handoff_requested": (decision.metadata or {}).get("session_control") == "HUMAN_HANDOFF",
|
||||||
|
"session_ended": (decision.metadata or {}).get("session_control") == "END_SESSION",
|
||||||
|
"continuity_signal": {
|
||||||
|
"decision": (decision.metadata or {}).get("continuity_decision"),
|
||||||
|
"confidence": decision.confidence if decision.method == "continuity" else None,
|
||||||
|
"reason": decision.reason if decision.method == "continuity" else None,
|
||||||
|
"profile": (decision.metadata or {}).get("continuity_profile"),
|
||||||
|
} if decision.method == "continuity" else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def billing_agent(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.agent.billing",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
return await self.billing.run(state)
|
||||||
|
|
||||||
|
async def product_agent(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.agent.product",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
return await self.product.run(state)
|
||||||
|
|
||||||
|
async def orders_agent(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.agent.orders",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
return await self.orders.run(state)
|
||||||
|
|
||||||
|
async def support_agent(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.agent.support",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
return await self.support.run(state)
|
||||||
|
|
||||||
|
async def supervisor_agent(self, state):
|
||||||
|
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
|
||||||
|
|
||||||
|
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
|
||||||
|
a conhecer quantos agentes foram acionados. Cada execução especializada
|
||||||
|
recebe o mesmo estado, mas com route/active_agent atualizados.
|
||||||
|
"""
|
||||||
|
plan = state.get("supervisor_plan") or {}
|
||||||
|
agents = plan.get("agents") or ["billing_agent"]
|
||||||
|
handlers = {
|
||||||
|
"billing_agent": self.billing.run,
|
||||||
|
"product_agent": self.product.run,
|
||||||
|
"orders_agent": self.orders.run,
|
||||||
|
"support_agent": self.support.run,
|
||||||
|
}
|
||||||
|
partials = []
|
||||||
|
mcp_results = []
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.supervisor_agent",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"agents": agents, "intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
for agent_name in agents:
|
||||||
|
handler = handlers.get(agent_name)
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
child_state = {**state, "route": agent_name, "active_agent": agent_name}
|
||||||
|
result = await handler(child_state)
|
||||||
|
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
|
||||||
|
mcp_results.extend(result.get("mcp_results") or [])
|
||||||
|
|
||||||
|
if len(partials) == 1:
|
||||||
|
answer = partials[0]["answer"]
|
||||||
|
else:
|
||||||
|
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
|
||||||
|
answer = (
|
||||||
|
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
|
||||||
|
f"{joined}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"supervisor_results": partials,
|
||||||
|
"mcp_results": mcp_results,
|
||||||
|
"next_state": "SUPERVISOR_ACTIVE",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handoff(self, state):
|
||||||
|
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
|
||||||
|
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
|
||||||
|
answer = (
|
||||||
|
"Vou redirecionar sua solicitação para o especialista correto. "
|
||||||
|
f"Destino sugerido: {target or 'agente especializado'}."
|
||||||
|
)
|
||||||
|
return {"answer": answer}
|
||||||
|
|
||||||
|
async def human_handoff(self, state):
|
||||||
|
session_id = state.get("conversation_key") or state.get("session_id")
|
||||||
|
async with self.telemetry.span("workflow.human_handoff", session_id=session_id):
|
||||||
|
answer = str(getattr(self.settings, "HUMAN_HANDOFF_MESSAGE", "Vou encaminhar seu atendimento para uma pessoa."))
|
||||||
|
await self.telemetry.event(
|
||||||
|
"session.human_handoff.requested",
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"session_control": "HUMAN_HANDOFF",
|
||||||
|
"human_handoff_requested": True,
|
||||||
|
"session_ended": False,
|
||||||
|
"next_state": "HUMAN_HANDOFF_REQUESTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def end_session(self, state):
|
||||||
|
session_id = state.get("conversation_key") or state.get("session_id")
|
||||||
|
async with self.telemetry.span("workflow.end_session", session_id=session_id):
|
||||||
|
answer = str(getattr(self.settings, "END_SESSION_MESSAGE", "Atendimento encerrado. Obrigado pelo contato."))
|
||||||
|
await self.telemetry.event(
|
||||||
|
"session.end.requested",
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"reason": (state.get("route_decision") or {}).get("reason"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"session_control": "END_SESSION",
|
||||||
|
"session_ended": True,
|
||||||
|
"human_handoff_requested": False,
|
||||||
|
"next_state": "SESSION_ENDED",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def output_supervisor(self, state):
|
||||||
|
"""Valida a resposta candidata com o OutputSupervisor corporativo.
|
||||||
|
|
||||||
|
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
|
||||||
|
agente gerar `answer` e antes dos judges/persistência, produzindo campos
|
||||||
|
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
|
||||||
|
"""
|
||||||
|
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
|
||||||
|
return {
|
||||||
|
"output_guardrails_already_applied": False,
|
||||||
|
"supervisor_action": "disabled",
|
||||||
|
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = state.get("answer") or ""
|
||||||
|
context = {
|
||||||
|
**(state.get("context") or {}),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
|
||||||
|
}
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.output_supervisor",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input=candidate,
|
||||||
|
):
|
||||||
|
decision = await self.output_supervisor_engine.evaluate(candidate, context)
|
||||||
|
action = decision.action.value
|
||||||
|
await self.telemetry.event(
|
||||||
|
"output_supervisor.completed",
|
||||||
|
{
|
||||||
|
"session_id": context["session_id"],
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"action": action,
|
||||||
|
"approved": decision.approved,
|
||||||
|
"guidance": decision.guidance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.observer.emit_ic(
|
||||||
|
"IC.OUTPUT_SUPERVISOR_COMPLETED",
|
||||||
|
{
|
||||||
|
"session_id": context["session_id"],
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
"action": action,
|
||||||
|
"approved": decision.approved,
|
||||||
|
"result_count": len(decision.results),
|
||||||
|
},
|
||||||
|
component="workflow.output_supervisor",
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
|
||||||
|
final_answer = decision.candidate
|
||||||
|
elif decision.action == RailAction.HANDOVER:
|
||||||
|
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
|
||||||
|
else:
|
||||||
|
final_answer = decision.fallback_message
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": final_answer,
|
||||||
|
"final_answer": final_answer,
|
||||||
|
"supervisor_action": action,
|
||||||
|
"supervisor_guidance": decision.guidance,
|
||||||
|
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
|
||||||
|
"supervisor_handover_reason": decision.handover_reason,
|
||||||
|
"output_supervisor_results": [
|
||||||
|
{
|
||||||
|
"code": r.code,
|
||||||
|
"action": r.action.value,
|
||||||
|
"reason": r.reason,
|
||||||
|
"guidance": r.guidance,
|
||||||
|
"metadata": r.metadata,
|
||||||
|
}
|
||||||
|
for r in decision.results
|
||||||
|
],
|
||||||
|
"output_guardrails_already_applied": True,
|
||||||
|
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||||
|
+ [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def output_guardrails(self, state):
|
||||||
|
if state.get("output_guardrails_already_applied"):
|
||||||
|
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
|
||||||
|
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.output_guardrails",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input=state.get("answer"),
|
||||||
|
):
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"001",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "output",
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
},
|
||||||
|
component="workflow.output_guardrails.start",
|
||||||
|
)
|
||||||
|
final, decisions = await self.guardrails.run_output(
|
||||||
|
state["answer"], state.get("context", {})
|
||||||
|
)
|
||||||
|
for _decision in decisions:
|
||||||
|
await self.guardrail_telemetry.evaluated("output", _decision)
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"002" if _decision.allowed else "004",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "output",
|
||||||
|
"rail_code": getattr(_decision, "code", None),
|
||||||
|
"allowed": bool(_decision.allowed),
|
||||||
|
"reason": getattr(_decision, "reason", None),
|
||||||
|
},
|
||||||
|
component="workflow.output_guardrails.decision",
|
||||||
|
)
|
||||||
|
if not _decision.allowed:
|
||||||
|
await self.guardrail_telemetry.blocked("output", _decision)
|
||||||
|
await self.telemetry.event(
|
||||||
|
"guardrails.output.completed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"decisions": [d.model_dump() for d in decisions],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.observer.emit_grl(
|
||||||
|
"009",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"phase": "output",
|
||||||
|
"blocked": any(not d.allowed for d in decisions),
|
||||||
|
"decision_count": len(decisions),
|
||||||
|
},
|
||||||
|
component="workflow.output_guardrails.final",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"final_answer": final,
|
||||||
|
"guardrail_decisions": state.get("guardrail_decisions", [])
|
||||||
|
+ [d.model_dump() for d in decisions],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def judge(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.judge",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
|
||||||
|
):
|
||||||
|
judge_context = dict(state.get("context", {}) or {})
|
||||||
|
judge_context["mcp_results"] = state.get("mcp_results", [])
|
||||||
|
judge_context["evidence"] = state.get("mcp_results", []) or judge_context.get("evidence")
|
||||||
|
judge_context["route"] = state.get("route")
|
||||||
|
judge_context["intent"] = state.get("intent")
|
||||||
|
# Judge sampling must see the finalized transaction state. These
|
||||||
|
# fields are populated by the agent/tool runtime before this node.
|
||||||
|
for key in (
|
||||||
|
"transaction_status",
|
||||||
|
"confirmation_required",
|
||||||
|
"confirmation_received",
|
||||||
|
"tool_policy_result",
|
||||||
|
"selected_tool_call",
|
||||||
|
"pending_tool_call",
|
||||||
|
):
|
||||||
|
judge_context[key] = state.get(key)
|
||||||
|
judge_context["transactional_tools"] = [
|
||||||
|
result.get("tool_name")
|
||||||
|
for result in state.get("mcp_results", [])
|
||||||
|
if isinstance(result, dict)
|
||||||
|
and (
|
||||||
|
(result.get("metadata") or {}).get("operation_type") == "transactional"
|
||||||
|
or result.get("awaiting_confirmation")
|
||||||
|
or result.get("transaction_status")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
results = await self.judges.evaluate_all(
|
||||||
|
state["user_text"], state["final_answer"], judge_context
|
||||||
|
)
|
||||||
|
for _result in results:
|
||||||
|
await self.judge_telemetry.evaluated(_result)
|
||||||
|
await self.telemetry.event(
|
||||||
|
"judges.completed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"results": [r.model_dump() for r in results],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"judge_results": [r.model_dump() for r in results]}
|
||||||
|
|
||||||
|
async def supervisor_review(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.supervisor_review",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input=state.get("final_answer"),
|
||||||
|
):
|
||||||
|
ok, answer = await self.supervisor.review(
|
||||||
|
state["final_answer"], state.get("context", {})
|
||||||
|
)
|
||||||
|
await self.telemetry.event(
|
||||||
|
"supervisor.review.completed",
|
||||||
|
{"session_id": state.get("session_id"), "approved": ok},
|
||||||
|
)
|
||||||
|
return {"final_answer": answer if ok else answer}
|
||||||
|
|
||||||
|
async def persist_long_term_memory(self, state):
|
||||||
|
result = await self.long_term_memory_manager.persist_turn(state)
|
||||||
|
return {"long_term_memory_write_result": result}
|
||||||
|
|
||||||
|
async def persist(self, state):
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.persist",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
input={"route": state.get("route"), "intent": state.get("intent")},
|
||||||
|
):
|
||||||
|
await self.observer.emit_ic(
|
||||||
|
"AGENT_COMPLETED",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state["session_id"],
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
"route_decision": state.get("route_decision"),
|
||||||
|
"judges": state.get("judge_results", []),
|
||||||
|
"mcp_tools": state.get("mcp_tools", []),
|
||||||
|
"mcp_results": state.get("mcp_results", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.observer.emit_noc(
|
||||||
|
"006",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state["session_id"],
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
"answer_chars": len(state.get("final_answer") or ""),
|
||||||
|
},
|
||||||
|
component="workflow.persist",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.telemetry.event(
|
||||||
|
"agent.completed",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state["session_id"],
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"route": state.get("route"),
|
||||||
|
"intent": state.get("intent"),
|
||||||
|
"answer_chars": len(state.get("final_answer") or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def ainvoke(self, state):
|
||||||
|
thread_id = state.get("conversation_key") or state["session_id"]
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
async with self.telemetry.span(
|
||||||
|
"workflow.langgraph.ainvoke",
|
||||||
|
session_id=state.get("conversation_key") or state.get("session_id"),
|
||||||
|
user_id=state.get("context", {}).get("user_id"),
|
||||||
|
input={"user_text": state.get("user_text")},
|
||||||
|
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
|
||||||
|
):
|
||||||
|
await self.workflow_telemetry.started("agent_workflow", state)
|
||||||
|
await self.observer.emit_noc(
|
||||||
|
"001",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"channel_id": (state.get("context") or {}).get("channel"),
|
||||||
|
"message_id": (state.get("context") or {}).get("message_id"),
|
||||||
|
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
|
||||||
|
},
|
||||||
|
component="workflow.ainvoke",
|
||||||
|
)
|
||||||
|
await self.observer.emit_ic(
|
||||||
|
"AGENT_STARTED",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"channel_id": (state.get("context") or {}).get("channel"),
|
||||||
|
"message_id": (state.get("context") or {}).get("message_id"),
|
||||||
|
"user_text_chars": len(state.get("user_text") or ""),
|
||||||
|
},
|
||||||
|
component="workflow.ainvoke",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await self.graph.ainvoke(state, config=config)
|
||||||
|
await self.workflow_telemetry.completed("agent_workflow", result)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
await self.workflow_telemetry.failed("agent_workflow", exc)
|
||||||
|
await self.observer.emit_noc(
|
||||||
|
"005",
|
||||||
|
{
|
||||||
|
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||||
|
"tenant_id": state.get("tenant_id"),
|
||||||
|
"agent_id": state.get("agent_id"),
|
||||||
|
"error": str(exc),
|
||||||
|
"exception_type": exc.__class__.__name__,
|
||||||
|
},
|
||||||
|
component="workflow.ainvoke",
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
default_agent_id: telecom_contas
|
||||||
|
agents:
|
||||||
|
- agent_id: telecom_contas
|
||||||
|
name: Agente Telecom Contas
|
||||||
|
description: Template de atendimento para faturas, produtos e suporte de telecom.
|
||||||
|
prompt_policy_path: ./config/agents/telecom_contas/prompt_policy.yaml
|
||||||
|
routing_config_path: ./config/routing.yaml
|
||||||
|
guardrails_config_path: ./config/agents/telecom_contas/guardrails.yaml
|
||||||
|
judges_config_path: ./config/agents/telecom_contas/judges.yaml
|
||||||
|
mcp_servers_config_path: ./config/mcp_servers.yaml
|
||||||
|
tools_config_path: ./config/tools.yaml
|
||||||
|
metadata:
|
||||||
|
domain: telecom
|
||||||
|
system_prefix: |
|
||||||
|
Você está executando o agent_template telecom_contas.
|
||||||
|
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
|
||||||
|
Não misture histórico ou decisões de outros agentes.
|
||||||
|
|
||||||
|
- agent_id: retail_orders
|
||||||
|
name: Agente Retail Pedidos
|
||||||
|
description: Template de varejo para pedidos, produtos, troca/devolução e garantia.
|
||||||
|
prompt_policy_path: ./config/agents/retail_orders/prompt_policy.yaml
|
||||||
|
routing_config_path: ./config/routing.yaml
|
||||||
|
guardrails_config_path: ./config/agents/retail_orders/guardrails.yaml
|
||||||
|
judges_config_path: ./config/agents/retail_orders/judges.yaml
|
||||||
|
mcp_servers_config_path: ./config/mcp_servers.yaml
|
||||||
|
tools_config_path: ./config/tools.yaml
|
||||||
|
metadata:
|
||||||
|
domain: retail
|
||||||
|
system_prefix: |
|
||||||
|
Você está executando o agent_template retail_orders.
|
||||||
|
Use somente políticas, memória, checkpoints, guardrails e judges deste agent_id.
|
||||||
|
Não misture histórico ou decisões de outros agentes.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
input:
|
||||||
|
- code: MSK
|
||||||
|
enabled: true
|
||||||
|
- code: VLOOP
|
||||||
|
enabled: true
|
||||||
|
output:
|
||||||
|
- code: REVPREC
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
judges:
|
||||||
|
- name: response_quality
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.7
|
||||||
|
- name: groundedness
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.6
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: retail_orders_prompt_policy
|
||||||
|
version: 1
|
||||||
|
description: Prompt base isolado do agente de varejo/pedidos.
|
||||||
|
system_prefix: |
|
||||||
|
Você é um agente corporativo de varejo especializado em pedidos, entrega, troca, devolução e garantia.
|
||||||
|
Seja claro, objetivo e não use regras de negócio de telecom neste agente.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
input:
|
||||||
|
- code: MSK
|
||||||
|
enabled: true
|
||||||
|
- code: VLOOP
|
||||||
|
enabled: true
|
||||||
|
output:
|
||||||
|
- code: REVPREC
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
enabled: true
|
||||||
|
fail_closed: true
|
||||||
|
profile: judge
|
||||||
|
|
||||||
|
judges:
|
||||||
|
- name: response_quality
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.7
|
||||||
|
|
||||||
|
- name: groundedness
|
||||||
|
enabled: true
|
||||||
|
threshold: 0.6
|
||||||
|
|
||||||
|
- name: sentiment
|
||||||
|
enabled: true
|
||||||
|
fail_on_negative: false
|
||||||
|
|
||||||
|
- name: tone
|
||||||
|
enabled: true
|
||||||
|
fail_closed: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: telecom_contas_prompt_policy
|
||||||
|
version: 1
|
||||||
|
description: Prompt base isolado do agente de telecom/contas.
|
||||||
|
system_prefix: |
|
||||||
|
Você é um agente corporativo de atendimento telecom especializado em faturas, produtos, VAS e suporte.
|
||||||
|
Seja claro, objetivo e não prometa execução operacional sem ferramenta ou confirmação válida.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
enabled: true
|
||||||
|
input:
|
||||||
|
- {code: INPUT_SIZE, enabled: true}
|
||||||
|
- {code: PINJ, enabled: true}
|
||||||
|
output:
|
||||||
|
- {code: DLEX_OUT, enabled: true}
|
||||||
|
- {code: EXTERNAL_BUSINESS_POLICY, type: external, class: app.extensions.example_guardrails:ExternalBusinessPolicyRail, enabled: true}
|
||||||
|
retrieval: []
|
||||||
|
tool: []
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
identity:
|
||||||
|
version: "2"
|
||||||
|
required:
|
||||||
|
- session_key
|
||||||
|
keys:
|
||||||
|
customer_key:
|
||||||
|
description: Cliente/assinante/consumidor canônico.
|
||||||
|
sources:
|
||||||
|
- business_context.customer_key
|
||||||
|
- customer_key
|
||||||
|
- msisdn
|
||||||
|
- customer_id
|
||||||
|
- user_id
|
||||||
|
- ani
|
||||||
|
- from
|
||||||
|
contract_key:
|
||||||
|
description: Contrato, conta, fatura, pedido ou asset principal.
|
||||||
|
sources:
|
||||||
|
- business_context.contract_key
|
||||||
|
- contract_key
|
||||||
|
- invoice_id
|
||||||
|
- current_invoice_number
|
||||||
|
- order_id
|
||||||
|
- pedido_id
|
||||||
|
- asset_id
|
||||||
|
interaction_key:
|
||||||
|
description: Chave externa da interação/call/chat vinda do canal.
|
||||||
|
sources:
|
||||||
|
- business_context.interaction_key
|
||||||
|
- interaction_key
|
||||||
|
- ura_call_id
|
||||||
|
- call_id
|
||||||
|
- message_id
|
||||||
|
account_key:
|
||||||
|
description: Conta de cobrança/conta comercial.
|
||||||
|
sources:
|
||||||
|
- business_context.account_key
|
||||||
|
- account_key
|
||||||
|
- account_id
|
||||||
|
- billing_account_id
|
||||||
|
resource_key:
|
||||||
|
description: Recurso/linha/produto/asset específico.
|
||||||
|
sources:
|
||||||
|
- business_context.resource_key
|
||||||
|
- resource_key
|
||||||
|
- asset_id
|
||||||
|
- product_id
|
||||||
|
- sku
|
||||||
|
session_key:
|
||||||
|
description: Sessão técnica estável já escopada por tenant e agente.
|
||||||
|
sources:
|
||||||
|
- business_context.session_key
|
||||||
|
- session_key
|
||||||
|
- conversation_key
|
||||||
|
- session_id
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
enabled: true
|
||||||
|
fail_closed: true
|
||||||
|
sample_rate: 1.0
|
||||||
|
judges:
|
||||||
|
- {name: response_quality, enabled: true, threshold: 0.70}
|
||||||
|
- {name: external_business_quality, type: external, class: app.extensions.example_judges:ExternalBusinessJudge, enabled: true, threshold: 0.50}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
mcp_parameter_mapping:
|
||||||
|
defaults:
|
||||||
|
use_mock: true
|
||||||
|
tools:
|
||||||
|
consultar_fatura:
|
||||||
|
map:
|
||||||
|
customer_key: msisdn
|
||||||
|
contract_key: invoice_id
|
||||||
|
interaction_key: ura_call_id
|
||||||
|
session_key: session_id
|
||||||
|
extract:
|
||||||
|
mes_referencia:
|
||||||
|
from: message
|
||||||
|
type: int
|
||||||
|
strategy: month_name_pt
|
||||||
|
description: 'Extrair mês citado na mensagem. janeiro=1, fevereiro=2, março=3,
|
||||||
|
abril=4, maio=5, junho=6, julho=7, agosto=8, setembro=9, outubro=10, novembro=11,
|
||||||
|
dezembro=12.
|
||||||
|
|
||||||
|
'
|
||||||
|
consultar_pagamentos:
|
||||||
|
map:
|
||||||
|
customer_key: msisdn
|
||||||
|
interaction_key: ura_call_id
|
||||||
|
session_key: session_id
|
||||||
|
consultar_plano:
|
||||||
|
map:
|
||||||
|
customer_key: msisdn
|
||||||
|
resource_key: asset_id
|
||||||
|
contract_key: asset_id
|
||||||
|
session_key: session_id
|
||||||
|
listar_servicos:
|
||||||
|
map:
|
||||||
|
customer_key: msisdn
|
||||||
|
session_key: session_id
|
||||||
|
consultar_pedido:
|
||||||
|
map:
|
||||||
|
customer_key: customer_id
|
||||||
|
session_key: session_id
|
||||||
|
extract:
|
||||||
|
order_id:
|
||||||
|
from: message
|
||||||
|
type: string
|
||||||
|
strategy: hybrid
|
||||||
|
description: Extraia somente o identificador do pedido informado explicitamente
|
||||||
|
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||||
|
mensagem.
|
||||||
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
|
group: 1
|
||||||
|
consultar_entrega:
|
||||||
|
map:
|
||||||
|
session_key: session_id
|
||||||
|
extract:
|
||||||
|
order_id:
|
||||||
|
from: message
|
||||||
|
type: string
|
||||||
|
strategy: hybrid
|
||||||
|
description: Extraia somente o identificador do pedido informado explicitamente
|
||||||
|
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||||
|
mensagem.
|
||||||
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
|
group: 1
|
||||||
|
cancelar_pedido:
|
||||||
|
map:
|
||||||
|
session_key: session_id
|
||||||
|
extract:
|
||||||
|
order_id:
|
||||||
|
from: message
|
||||||
|
type: string
|
||||||
|
strategy: hybrid
|
||||||
|
description: Extraia somente o identificador do pedido informado explicitamente pelo usuário. Retorne null quando não houver identificador de pedido na mensagem.
|
||||||
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
|
group: 1
|
||||||
|
|
||||||
|
solicitar_troca:
|
||||||
|
map:
|
||||||
|
session_key: session_id
|
||||||
|
defaults:
|
||||||
|
reason: Solicitação aberta pelo atendimento conversacional.
|
||||||
|
extract:
|
||||||
|
order_id:
|
||||||
|
from: message
|
||||||
|
type: string
|
||||||
|
strategy: hybrid
|
||||||
|
description: Extraia somente o identificador do pedido informado explicitamente
|
||||||
|
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||||
|
mensagem.
|
||||||
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
|
group: 1
|
||||||
|
solicitar_devolucao:
|
||||||
|
map:
|
||||||
|
session_key: session_id
|
||||||
|
defaults:
|
||||||
|
reason: Solicitação aberta pelo atendimento conversacional.
|
||||||
|
extract:
|
||||||
|
order_id:
|
||||||
|
from: message
|
||||||
|
type: string
|
||||||
|
strategy: hybrid
|
||||||
|
description: Extraia somente o identificador do pedido informado explicitamente
|
||||||
|
pelo usuário. Retorne null quando não houver identificador de pedido na
|
||||||
|
mensagem.
|
||||||
|
pattern: (?i)\\b(?:pedido|order)\\s*[:#-]?\\s*([A-Z0-9-]+)\\b
|
||||||
|
group: 1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
servers:
|
||||||
|
telecom:
|
||||||
|
transport: http
|
||||||
|
endpoint: http://telecom-mcp:8100/mcp
|
||||||
|
enabled: true
|
||||||
|
description: MCP Server Telecom via docker-compose.
|
||||||
|
|
||||||
|
retail:
|
||||||
|
transport: http
|
||||||
|
endpoint: http://retail-mcp:8200/mcp
|
||||||
|
enabled: true
|
||||||
|
description: MCP Server Retail via docker-compose.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# MCP servers registry.
|
||||||
|
# transport=http keeps the legacy framework mock contract:
|
||||||
|
# GET <endpoint>/tools/list
|
||||||
|
# POST <endpoint>/tools/call
|
||||||
|
# transport=fastmcp uses official MCP Streamable HTTP, typically endpoint http://host:port/mcp
|
||||||
|
# transport=sse uses official MCP SSE, typically endpoint http://host:port/sse
|
||||||
|
servers:
|
||||||
|
# telecom:
|
||||||
|
# enabled: true
|
||||||
|
# transport: fastmcp
|
||||||
|
# endpoint: http://localhost:8001/mcp
|
||||||
|
# description: Telecom FastMCP server using official MCP protocol
|
||||||
|
#
|
||||||
|
# retail:
|
||||||
|
# enabled: true
|
||||||
|
# transport: fastmcp
|
||||||
|
# endpoint: http://localhost:8002/mcp
|
||||||
|
# description: Retail FastMCP server using official MCP protocol
|
||||||
|
|
||||||
|
telecom:
|
||||||
|
enabled: true
|
||||||
|
transport: http
|
||||||
|
endpoint: http://localhost:8100/mcp
|
||||||
|
description: Telecom legacy HTTP mock MCP server
|
||||||
|
|
||||||
|
retail:
|
||||||
|
enabled: true
|
||||||
|
transport: http
|
||||||
|
endpoint: http://localhost:8200/mcp
|
||||||
|
description: Retail legacy HTTP mock MCP server
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
tone:
|
||||||
|
style: "claro, objetivo, empático"
|
||||||
|
forbidden_phrases:
|
||||||
|
- "procure atendimento humano"
|
||||||
|
vocabulary:
|
||||||
|
preferred:
|
||||||
|
fatura: "fatura"
|
||||||
|
contestacao: "contestação"
|
||||||
|
intents:
|
||||||
|
billing_agent:
|
||||||
|
- fatura
|
||||||
|
- boleto
|
||||||
|
- cobrança
|
||||||
|
- segunda via
|
||||||
|
product_agent:
|
||||||
|
- plano
|
||||||
|
- produto
|
||||||
|
- oferta
|
||||||
|
- serviço
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Roteamento enterprise configurável com MCP-aware intents.
|
||||||
|
router:
|
||||||
|
# mode também pode ser definido por variável de ambiente ROUTING_MODE.
|
||||||
|
# Valores: router | supervisor
|
||||||
|
mode: router
|
||||||
|
fallback_agent: billing_agent
|
||||||
|
confidence_threshold: 0.65
|
||||||
|
allow_handoff: true
|
||||||
|
|
||||||
|
state_policies:
|
||||||
|
- state: WAITING_BILLING_CONFIRMATION
|
||||||
|
agent: billing_agent
|
||||||
|
description: Mantém mensagens curtas como "sim" ou "não" no fluxo de fatura.
|
||||||
|
- state: WAITING_PRODUCT_CONFIRMATION
|
||||||
|
agent: product_agent
|
||||||
|
description: Mantém confirmações no fluxo de produtos/serviços.
|
||||||
|
- state: WAITING_ORDER_CONFIRMATION
|
||||||
|
agent: orders_agent
|
||||||
|
description: Mantém confirmações no fluxo de pedidos.
|
||||||
|
- state: WAITING_SUPPORT_CONFIRMATION
|
||||||
|
agent: support_agent
|
||||||
|
description: Mantém confirmações no fluxo de suporte retail.
|
||||||
|
- state: COLLECTING_BILLING_PARAMETERS
|
||||||
|
agent: billing_agent
|
||||||
|
description: Mantém a coleta de parâmetros no fluxo de faturamento.
|
||||||
|
- state: COLLECTING_PRODUCT_PARAMETERS
|
||||||
|
agent: product_agent
|
||||||
|
description: Mantém a coleta de parâmetros no fluxo de produtos e serviços.
|
||||||
|
- state: COLLECTING_ORDER_PARAMETERS
|
||||||
|
agent: orders_agent
|
||||||
|
description: Mantém a coleta de parâmetros no fluxo de pedidos.
|
||||||
|
- state: COLLECTING_SUPPORT_PARAMETERS
|
||||||
|
agent: support_agent
|
||||||
|
description: Mantém a coleta de parâmetros no fluxo transacional de suporte retail.
|
||||||
|
|
||||||
|
intents:
|
||||||
|
- name: billing_invoice_explanation
|
||||||
|
domain: telecom
|
||||||
|
agent: billing_agent
|
||||||
|
description: Dúvidas sobre fatura, cobrança, vencimento, segunda via, contestação e valores.
|
||||||
|
priority: 10
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_fatura
|
||||||
|
- consultar_pagamentos
|
||||||
|
keywords:
|
||||||
|
- fatura
|
||||||
|
- conta
|
||||||
|
- cobrança
|
||||||
|
- boleto
|
||||||
|
- vencimento
|
||||||
|
- segunda via
|
||||||
|
- contestar
|
||||||
|
- valor alto
|
||||||
|
- invoice
|
||||||
|
examples:
|
||||||
|
- Minha fatura veio alta.
|
||||||
|
- Quero entender uma cobrança.
|
||||||
|
- Preciso da segunda via da conta.
|
||||||
|
|
||||||
|
- name: product_services_information
|
||||||
|
domain: telecom
|
||||||
|
agent: product_agent
|
||||||
|
description: Dúvidas sobre plano, pacote, produto, serviço, VAS, internet, roaming e benefícios.
|
||||||
|
priority: 20
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_plano
|
||||||
|
- listar_servicos
|
||||||
|
keywords:
|
||||||
|
- plano
|
||||||
|
- serviço
|
||||||
|
- pacote
|
||||||
|
- internet
|
||||||
|
- roaming
|
||||||
|
- vas
|
||||||
|
- benefício
|
||||||
|
- assinatura
|
||||||
|
examples:
|
||||||
|
- Quais serviços estão ativos no meu plano?
|
||||||
|
- Quero saber sobre meu pacote de internet.
|
||||||
|
- Tenho roaming internacional?
|
||||||
|
|
||||||
|
|
||||||
|
- name: retail_order_cancel
|
||||||
|
domain: retail
|
||||||
|
agent: orders_agent
|
||||||
|
description: Cancelamento explícito de pedido ou compra.
|
||||||
|
priority: 20
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_pedido
|
||||||
|
- cancelar_pedido
|
||||||
|
keywords:
|
||||||
|
- cancelar pedido
|
||||||
|
- cancelamento do pedido
|
||||||
|
- cancelar a compra
|
||||||
|
- cancelar compra
|
||||||
|
examples:
|
||||||
|
- Quero cancelar meu pedido.
|
||||||
|
- Cancele o pedido.
|
||||||
|
- Quero cancelar a compra.
|
||||||
|
|
||||||
|
- name: retail_order_tracking
|
||||||
|
domain: retail
|
||||||
|
agent: orders_agent
|
||||||
|
description: Consulta de pedido, entrega, rastreamento, atraso e status de compra.
|
||||||
|
priority: 30
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_pedido
|
||||||
|
- consultar_entrega
|
||||||
|
keywords:
|
||||||
|
- pedido
|
||||||
|
- entrega
|
||||||
|
- rastreio
|
||||||
|
- rastreamento
|
||||||
|
- encomenda
|
||||||
|
- compra
|
||||||
|
- atraso
|
||||||
|
- correios
|
||||||
|
examples:
|
||||||
|
- Meu pedido não chegou.
|
||||||
|
- Quero rastrear minha entrega.
|
||||||
|
- Qual é o status da minha compra?
|
||||||
|
|
||||||
|
- name: retail_support_exchange_return
|
||||||
|
domain: retail
|
||||||
|
agent: support_agent
|
||||||
|
description: Suporte, troca, devolução, garantia e problema com produto.
|
||||||
|
priority: 25
|
||||||
|
mcp_tools:
|
||||||
|
- consultar_pedido
|
||||||
|
- solicitar_troca
|
||||||
|
- solicitar_devolucao
|
||||||
|
keywords:
|
||||||
|
- solicitar devolução
|
||||||
|
- devolver pedido
|
||||||
|
- solicitar troca
|
||||||
|
- troca
|
||||||
|
- devolução
|
||||||
|
- devolver
|
||||||
|
- garantia
|
||||||
|
- defeito
|
||||||
|
- produto quebrado
|
||||||
|
- suporte
|
||||||
|
- arrependimento
|
||||||
|
examples:
|
||||||
|
- Quero trocar um produto.
|
||||||
|
- Meu produto veio com defeito.
|
||||||
|
- Como faço uma devolução?
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
# Arquivo opcional da aplicação. A ausência mantém o comportamento dos
|
||||||
|
# templates anteriores e as políticas legadas declaradas em tools.yaml.
|
||||||
|
defaults:
|
||||||
|
operation_type: read_only
|
||||||
|
require_confirmation: false
|
||||||
|
|
||||||
|
tool_policies:
|
||||||
|
cancelar_pedido:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
requires: [order_id]
|
||||||
|
|
||||||
|
|
||||||
|
solicitar_troca:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
|
||||||
|
solicitar_devolucao:
|
||||||
|
operation_type: transactional
|
||||||
|
require_confirmation: true
|
||||||
|
|
||||||
|
# Exemplo para uma operação real que só pode executar após confirmação:
|
||||||
|
# cancelar_servico:
|
||||||
|
# operation_type: transactional
|
||||||
|
# require_confirmation: true
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
tools:
|
||||||
|
consultar_fatura:
|
||||||
|
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
|
||||||
|
mcp_server: telecom
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
msisdn: string
|
||||||
|
invoice_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- fatura
|
||||||
|
- conta
|
||||||
|
- boleto
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.invoice
|
||||||
|
consultar_pagamentos:
|
||||||
|
description: Consulta histórico de pagamentos do cliente.
|
||||||
|
mcp_server: telecom
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
msisdn: string
|
||||||
|
selection_keywords:
|
||||||
|
- pagamento
|
||||||
|
- pagamentos
|
||||||
|
consultar_plano:
|
||||||
|
description: Consulta plano ativo e atributos comerciais.
|
||||||
|
mcp_server: telecom
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
msisdn: string
|
||||||
|
asset_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- plano
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: telecom.plan
|
||||||
|
listar_servicos:
|
||||||
|
description: Lista serviços ativos e adicionais VAS.
|
||||||
|
mcp_server: telecom
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
msisdn: string
|
||||||
|
selection_keywords:
|
||||||
|
- serviços
|
||||||
|
- servicos
|
||||||
|
- vas
|
||||||
|
consultar_pedido:
|
||||||
|
description: Consulta pedido de varejo por order_id/customer_id.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
customer_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- consultar pedido
|
||||||
|
- status do pedido
|
||||||
|
- pedido
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.order
|
||||||
|
consultar_entrega:
|
||||||
|
description: Consulta entrega e rastreamento do pedido.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- entrega
|
||||||
|
- rastreio
|
||||||
|
- rastreamento
|
||||||
|
- transportadora
|
||||||
|
- previsão
|
||||||
|
response:
|
||||||
|
mode: renderer
|
||||||
|
renderer: retail.delivery
|
||||||
|
cancelar_pedido:
|
||||||
|
description: Simula o cancelamento de um pedido de varejo.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: action
|
||||||
|
requires:
|
||||||
|
- order_id
|
||||||
|
confirmation_required: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
selection_keywords:
|
||||||
|
- cancelar pedido
|
||||||
|
- cancelamento do pedido
|
||||||
|
- cancelar compra
|
||||||
|
- cancelar a compra
|
||||||
|
|
||||||
|
|
||||||
|
solicitar_troca:
|
||||||
|
description: Simula abertura de solicitação de troca.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: action
|
||||||
|
requires:
|
||||||
|
- order_id
|
||||||
|
- reason
|
||||||
|
confirmation_required: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
reason: string
|
||||||
|
selection_keywords:
|
||||||
|
- solicitar troca
|
||||||
|
- trocar
|
||||||
|
- troca
|
||||||
|
- defeito
|
||||||
|
- quebrado
|
||||||
|
solicitar_devolucao:
|
||||||
|
description: Simula abertura de solicitação de devolução.
|
||||||
|
mcp_server: retail
|
||||||
|
enabled: true
|
||||||
|
tool_type: action
|
||||||
|
requires:
|
||||||
|
- order_id
|
||||||
|
- reason
|
||||||
|
confirmation_required: true
|
||||||
|
args_schema:
|
||||||
|
order_id: string
|
||||||
|
reason: string
|
||||||
|
selection_keywords:
|
||||||
|
- solicitar devolução
|
||||||
|
- solicitar devolucao
|
||||||
|
- devolver pedido
|
||||||
|
- devolver
|
||||||
|
- devolução
|
||||||
|
- devolucao
|
||||||
|
- arrependimento
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
|
||||||
|
|
||||||
|
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
|
||||||
|
|
||||||
|
## 1. Analytics e Pub/Sub
|
||||||
|
|
||||||
|
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agent_framework.analytics.factory import create_analytics_publisher
|
||||||
|
from agent_framework.observability.observer import AgentObserver
|
||||||
|
|
||||||
|
analytics = create_analytics_publisher(settings)
|
||||||
|
observer = AgentObserver(analytics=analytics)
|
||||||
|
```
|
||||||
|
|
||||||
|
Com isso, o mesmo backend pode publicar em:
|
||||||
|
|
||||||
|
- OCI Streaming
|
||||||
|
- GCP Pub/Sub
|
||||||
|
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
|
||||||
|
- Noop, quando analytics estiver desligado
|
||||||
|
|
||||||
|
## 2. Configuração mínima
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_ANALYTICS=true
|
||||||
|
ANALYTICS_PROVIDERS=pubsub
|
||||||
|
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_ANALYTICS=true
|
||||||
|
ANALYTICS_PROVIDERS=oci_streaming,pubsub
|
||||||
|
ENABLE_OCI_STREAMING=true
|
||||||
|
OCI_STREAM_ENDPOINT=<endpoint>
|
||||||
|
OCI_STREAM_OCID=<stream-ocid>
|
||||||
|
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Observer corporativo
|
||||||
|
|
||||||
|
O workflow recebeu emissão automática dos principais eventos corporativos:
|
||||||
|
|
||||||
|
- `NOC.001`: início do workflow
|
||||||
|
- `NOC.005`: exceção fatal no workflow
|
||||||
|
- `NOC.006`: fim do workflow antes da resposta final
|
||||||
|
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
|
||||||
|
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
|
||||||
|
|
||||||
|
## 4. OutputSupervisor
|
||||||
|
|
||||||
|
Foi inserido um novo nó LangGraph:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
|
||||||
|
```
|
||||||
|
|
||||||
|
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
|
||||||
|
|
||||||
|
- `allow`
|
||||||
|
- `sanitize`
|
||||||
|
- `retry`
|
||||||
|
- `block`
|
||||||
|
- `handover`
|
||||||
|
- `observe`
|
||||||
|
|
||||||
|
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
|
||||||
|
|
||||||
|
## 5. Campos adicionados ao AgentState
|
||||||
|
|
||||||
|
```python
|
||||||
|
supervisor_action: str
|
||||||
|
supervisor_guidance: str
|
||||||
|
supervisor_attempt: int
|
||||||
|
supervisor_handover_reason: str
|
||||||
|
output_supervisor_results: list[dict]
|
||||||
|
output_guardrails_already_applied: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Arquivos alterados
|
||||||
|
|
||||||
|
- `agent_template_backend/app/main.py`
|
||||||
|
- `agent_template_backend/app/workflows/agent_graph.py`
|
||||||
|
- `agent_template_backend/app/state.py`
|
||||||
|
- `agent_template_backend/.env`
|
||||||
|
- `agent_template_backend/requirements.txt`
|
||||||
|
- `agent_framework/src/agent_framework/config/settings.py`
|
||||||
|
|
||||||
|
## 7. Observação importante
|
||||||
|
|
||||||
|
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Como usar IC, NOC e GRL no Template Backend
|
||||||
|
|
||||||
|
## IC — Item de Controle
|
||||||
|
|
||||||
|
Use IC para registrar eventos de negócio relevantes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
await observer.emit_ic(
|
||||||
|
"IC.FATURA_CONSULTADA",
|
||||||
|
{"session_id": session_id, "invoice_id": invoice_id},
|
||||||
|
component="billing_agent",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## NOC — Evento operacional
|
||||||
|
|
||||||
|
Use NOC para saúde técnica, latência, erros e checkpoints operacionais.
|
||||||
|
|
||||||
|
```python
|
||||||
|
await observer.emit_noc(
|
||||||
|
"003",
|
||||||
|
{"session_id": session_id, "resourceName": "ADB", "latencyMs": 120},
|
||||||
|
component="repository",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GRL — Evento de guardrail
|
||||||
|
|
||||||
|
Normalmente o framework emite GRL automaticamente. Use manualmente apenas para
|
||||||
|
rails customizados dentro do agente.
|
||||||
|
|
||||||
|
```python
|
||||||
|
await observer.emit_grl(
|
||||||
|
"OBSERVE",
|
||||||
|
{"session_id": session_id, "rail_code": "CUSTOM_POLICY"},
|
||||||
|
component="custom_rail",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Onde já existe no template
|
||||||
|
|
||||||
|
- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow.
|
||||||
|
- `app/agents/runtime.py` emite IC para MCP/tools.
|
||||||
|
- `app/agents/*_agent.py` contém exemplos dentro do método `run()`.
|
||||||
|
- `app/examples/` contém exemplos isolados.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Backends atualizados para ConversationSummaryMemory
|
||||||
|
|
||||||
|
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
|
||||||
|
|
||||||
|
## O que mudou
|
||||||
|
|
||||||
|
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
|
||||||
|
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
|
||||||
|
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
|
||||||
|
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
|
||||||
|
- Quando habilitado por `.env`, o prompt passa a receber:
|
||||||
|
- resumo acumulado da conversa;
|
||||||
|
- últimas mensagens completas;
|
||||||
|
- mensagem atual;
|
||||||
|
- BusinessContext;
|
||||||
|
- MCP results;
|
||||||
|
- RAG context e metadata.
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||||
|
MEMORY_CONTEXT_STRATEGY=summary
|
||||||
|
MEMORY_HISTORY_LIMIT=80
|
||||||
|
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||||
|
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||||
|
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||||
|
MEMORY_SUMMARY_USE_LLM=true
|
||||||
|
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||||
|
MEMORY_INJECT_SUMMARY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backends alterados
|
||||||
|
|
||||||
|
- `backoffice_convertido_framework`
|
||||||
|
- `agent_template_backend`
|
||||||
|
- `agent_template_backend_day_zero`
|
||||||
|
|
||||||
|
## Observação importante
|
||||||
|
|
||||||
|
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
|
||||||
|
|
||||||
|
- `agent_framework.memory.summary_memory`
|
||||||
|
- `agent_framework.memory.summary_store`
|
||||||
|
- `AgentRuntimeMixin.prepare_memory_context()`
|
||||||
|
- `AgentRuntimeMixin.build_messages()` com injeção de memória
|
||||||
|
|
||||||
|
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# FRAMEWORK_CHANNEL_INPUT_MODE
|
||||||
|
|
||||||
|
This backend setting controls what kind of channel input the Agent Framework backend accepts.
|
||||||
|
|
||||||
|
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
|
||||||
|
|
||||||
|
## Recommended enterprise setup
|
||||||
|
|
||||||
|
In the external channel gateway service:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
In this backend:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
External channel / browser / customer adapter
|
||||||
|
↓
|
||||||
|
channel_gateway:7000
|
||||||
|
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||||
|
↓ GatewayRequest
|
||||||
|
agent_template_backend:8000
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||||
|
↓
|
||||||
|
LangGraph / Agents / MCP / Guardrails
|
||||||
|
```
|
||||||
|
|
||||||
|
## Valid direct request to backend in external mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://localhost:8000/gateway/message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"channel": "web",
|
||||||
|
"tenant_id": "default",
|
||||||
|
"agent_id": "telecom_contas",
|
||||||
|
"payload": {
|
||||||
|
"message": "Quero consultar minha fatura",
|
||||||
|
"session_id": "backend-external-ok-001"
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalid direct request to backend in external mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -s -X POST "http://localhost:8000/gateway/message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"message": "Quero consultar minha fatura",
|
||||||
|
"session_id": "raw-payload-error-001"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: HTTP 422.
|
||||||
|
|
||||||
|
## Legacy compatibility
|
||||||
|
|
||||||
|
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
|
||||||
|
```
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Guardrails paralelos fail-fast e Observer IC
|
||||||
|
|
||||||
|
## O que foi implementado
|
||||||
|
|
||||||
|
### 1. ParallelRailExecutor
|
||||||
|
|
||||||
|
Arquivo principal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_framework/src/agent_framework/guardrails/parallel_executor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Também foi criado um alias de compatibilidade:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_framework/src/agent_framework/guardrails/executor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Esse alias evita erro quando algum código antigo importar:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agent_framework.guardrails.executor import ParallelRailExecutor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Execução paralela no GuardrailPipeline
|
||||||
|
|
||||||
|
Arquivo alterado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_framework/src/agent_framework/guardrails/pipeline.py
|
||||||
|
```
|
||||||
|
|
||||||
|
O pipeline continua retornando o contrato antigo:
|
||||||
|
|
||||||
|
```python
|
||||||
|
(texto_final, list[RailDecision])
|
||||||
|
```
|
||||||
|
|
||||||
|
mas internamente pode executar rails em paralelo com fail-fast.
|
||||||
|
|
||||||
|
### 3. Execução paralela no OutputSupervisor
|
||||||
|
|
||||||
|
Arquivo alterado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_framework/src/agent_framework/guardrails/output_supervisor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado.
|
||||||
|
|
||||||
|
### 4. Configuração
|
||||||
|
|
||||||
|
Novas configurações:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_PARALLEL_GUARDRAILS=true
|
||||||
|
GUARDRAILS_FAIL_FAST=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Também foram adicionadas em:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_framework/src/agent_framework/config/settings.py
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
agent_template_backend/.env
|
||||||
|
agent_template_backend_day_zero/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Observer IC
|
||||||
|
|
||||||
|
O `AgentObserver` já tinha `emit_ic()`.
|
||||||
|
|
||||||
|
Foi complementada a API global compatível com FIRST/TIM:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agent_framework.observer import ic, aic, noc, anoc, grl, agrl
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ic("AGENT_COMPLETED", data={"session_id": "..."})
|
||||||
|
await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. ICs automáticos no template backend
|
||||||
|
|
||||||
|
O backend emite agora:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IC.AGENT_STARTED
|
||||||
|
IC.ROUTE_SELECTED
|
||||||
|
IC.MCP_TOOL_CALLED
|
||||||
|
IC.TOOL_CALLED
|
||||||
|
IC.AGENT_COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
Além dos eventos já existentes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NOC.001
|
||||||
|
NOC.005
|
||||||
|
NOC.006
|
||||||
|
GRL.001 ... GRL.009
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validações executadas
|
||||||
|
|
||||||
|
Foram executadas validações locais com `PYTHONPATH=agent_framework/src`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke tests executados:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Import de ParallelRailExecutor via agent_framework.guardrails
|
||||||
|
2. Import de ParallelRailExecutor via agent_framework.guardrails.executor
|
||||||
|
3. Execução fail-fast: FastBlock cancela SlowAllow
|
||||||
|
4. GuardrailPipeline paralelo retorna RailDecision legado
|
||||||
|
5. OutputSupervisor paralelo retorna RailAction.BLOCK
|
||||||
|
6. API global observer.ic/noc/grl/aic/anoc/agrl
|
||||||
|
```
|
||||||
|
|
||||||
|
Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Implementação IC/NOC/GRL preservando lógica existente
|
||||||
|
|
||||||
|
Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa.
|
||||||
|
|
||||||
|
## IC adicionados nos agentes
|
||||||
|
|
||||||
|
Cada agente agora emite eventos de negócio sem alterar a resposta final:
|
||||||
|
|
||||||
|
- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED`
|
||||||
|
- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED`
|
||||||
|
- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED`
|
||||||
|
- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED`
|
||||||
|
- `IC.<AGENT>_MCP_CONTEXT_COLLECTED` quando houver dados MCP
|
||||||
|
- `IC.<AGENT>_RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado
|
||||||
|
|
||||||
|
O mixin `AgentRuntimeMixin` também emite:
|
||||||
|
|
||||||
|
- `IC.MCP_TOOL_CALLED` antes da chamada MCP
|
||||||
|
- `IC.TOOL_CALLED` após a chamada MCP
|
||||||
|
|
||||||
|
## NOC
|
||||||
|
|
||||||
|
O workflow já emite eventos operacionais principais:
|
||||||
|
|
||||||
|
- `NOC.001` no início da execução
|
||||||
|
- `NOC.005` em exceção fatal
|
||||||
|
- `NOC.006` na persistência/finalização
|
||||||
|
|
||||||
|
## GRL
|
||||||
|
|
||||||
|
O backend agora também exemplifica emissão GRL no workflow:
|
||||||
|
|
||||||
|
- `GRL.001` início do pipeline de guardrails
|
||||||
|
- `GRL.002` decisão allow
|
||||||
|
- `GRL.004` decisão block
|
||||||
|
- `GRL.009` decisão final agregada
|
||||||
|
|
||||||
|
Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída.
|
||||||
|
|
||||||
|
## Garantia
|
||||||
|
|
||||||
|
A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Langfuse single trace observer fix
|
||||||
|
|
||||||
|
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
|
||||||
|
|
||||||
|
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Validação da versão com IC/NOC/GRL
|
||||||
|
|
||||||
|
Validações executadas nesta geração:
|
||||||
|
|
||||||
|
1. `python -m compileall -q agent_template_backend/app`
|
||||||
|
- Resultado: OK.
|
||||||
|
|
||||||
|
2. Smoke test dos agentes com LLM fake e Observer fake:
|
||||||
|
- `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||||
|
- `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||||
|
- `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||||
|
- `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||||
|
|
||||||
|
3. Verificação de regressão:
|
||||||
|
- Nenhum agente retorna `Template Enterprise ativo`.
|
||||||
|
- A lógica LLM/MCP/RAG/cache existente foi preservada.
|
||||||
|
|
||||||
|
## Eventos adicionados
|
||||||
|
|
||||||
|
### IC
|
||||||
|
|
||||||
|
Nos agentes:
|
||||||
|
|
||||||
|
- `IC.BILLING_AGENT_STARTED`
|
||||||
|
- `IC.BILLING_MCP_CONTEXT_COLLECTED`
|
||||||
|
- `IC.BILLING_RAG_CONTEXT_RETRIEVED`
|
||||||
|
- `IC.BILLING_AGENT_COMPLETED`
|
||||||
|
- `IC.ORDERS_AGENT_STARTED`
|
||||||
|
- `IC.ORDERS_MCP_CONTEXT_COLLECTED`
|
||||||
|
- `IC.ORDERS_RAG_CONTEXT_RETRIEVED`
|
||||||
|
- `IC.ORDERS_AGENT_COMPLETED`
|
||||||
|
- `IC.PRODUCT_AGENT_STARTED`
|
||||||
|
- `IC.PRODUCT_MCP_CONTEXT_COLLECTED`
|
||||||
|
- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED`
|
||||||
|
- `IC.PRODUCT_AGENT_COMPLETED`
|
||||||
|
- `IC.SUPPORT_AGENT_STARTED`
|
||||||
|
- `IC.SUPPORT_MCP_CONTEXT_COLLECTED`
|
||||||
|
- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED`
|
||||||
|
- `IC.SUPPORT_AGENT_COMPLETED`
|
||||||
|
|
||||||
|
No runtime MCP:
|
||||||
|
|
||||||
|
- `IC.MCP_TOOL_CALLED`
|
||||||
|
- `IC.TOOL_CALLED`
|
||||||
|
|
||||||
|
### NOC
|
||||||
|
|
||||||
|
Já integrados no workflow:
|
||||||
|
|
||||||
|
- `NOC.001` início da execução
|
||||||
|
- `NOC.005` erro fatal
|
||||||
|
- `NOC.006` finalização/persistência
|
||||||
|
|
||||||
|
### GRL
|
||||||
|
|
||||||
|
No workflow de guardrails:
|
||||||
|
|
||||||
|
- `GRL.001` início da avaliação
|
||||||
|
- `GRL.002` allow
|
||||||
|
- `GRL.004` block
|
||||||
|
- `GRL.009` decisão final
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
compileall app: OK
|
||||||
|
Arquivos de exemplos IC/NOC/GRL adicionados.
|
||||||
|
Agentes preservam implementação original comentada.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
profiles:
|
||||||
|
default:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 2048
|
||||||
|
supervisor:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 700
|
||||||
|
router:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 500
|
||||||
|
guardrail:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 600
|
||||||
|
grl:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 700
|
||||||
|
judge:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 800
|
||||||
|
rag_rewriter:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 300
|
||||||
|
rag_compressor:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 1200
|
||||||
|
rag_generation:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.1
|
||||||
|
max_tokens: 1800
|
||||||
|
summary_memory:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.1
|
||||||
|
max_tokens: 1200
|
||||||
|
noc:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 700
|
||||||
|
billing_agent:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.2
|
||||||
|
product_agent:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.2
|
||||||
|
backoffice_agent:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1
|
||||||
|
temperature: 0.2
|
||||||
|
mcp_parameter_extraction:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1-mini
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 80
|
||||||
|
timeout_seconds: 5
|
||||||
|
|
||||||
|
transaction_parameter_extraction:
|
||||||
|
provider: oci_openai
|
||||||
|
model: openai.gpt-4.1-mini
|
||||||
|
temperature: 0
|
||||||
|
max_tokens: 500
|
||||||
|
timeout_seconds: 8
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
pydantic>=2.8.0
|
||||||
|
pydantic-settings>=2.4.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
langgraph>=0.2.60
|
||||||
|
langchain-core>=0.3.0
|
||||||
|
openai>=1.60.0
|
||||||
|
oci>=2.130.0
|
||||||
|
oracledb>=2.4.0
|
||||||
|
pymongo>=4.8.0
|
||||||
|
redis>=5.0.0
|
||||||
|
PyYAML>=6.0.2
|
||||||
|
|
||||||
|
langfuse>=3.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
opentelemetry-api>=1.27.0
|
||||||
|
opentelemetry-sdk>=1.27.0
|
||||||
|
opentelemetry-exporter-otlp-proto-http>=1.27.0
|
||||||
|
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
google-cloud-pubsub>=2.28.0
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
ENABLE_LONG_TERM_MEMORY=True,
|
||||||
|
LONG_TERM_MEMORY_PROVIDER='sqlite',
|
||||||
|
LONG_TERM_MEMORY_SQLITE_PATH=f'{d}/memory.db',
|
||||||
|
LONG_TERM_MEMORY_TABLE='agentfw_long_term_memory',
|
||||||
|
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS=20,
|
||||||
|
LONG_TERM_MEMORY_MIN_CONFIDENCE=0.70,
|
||||||
|
LONG_TERM_MEMORY_AUTO_EXTRACT=True,
|
||||||
|
)
|
||||||
|
manager = create_long_term_memory_manager(settings)
|
||||||
|
first = {'tenant_id':'default','agent_id':'memory_test','session_id':'a','user_text':'Me chame de Cris. Minha linguagem preferida é Python. Meu projeto atual se chama Atlas.','context':{'business_context':{'customer_key':'MEM-001'}}}
|
||||||
|
assert (await manager.persist_turn(first))['saved'] >= 3
|
||||||
|
second = {'tenant_id':'default','agent_id':'memory_test','session_id':'b','context':{'business_context':{'customer_key':'MEM-001'}}}
|
||||||
|
values = {item.key:item.value for item in await manager.load(second)}
|
||||||
|
assert values['preferred_name'].lower() == 'cris'
|
||||||
|
assert values['preferred_language'].lower() == 'python'
|
||||||
|
assert values['current_project'].lower() == 'atlas'
|
||||||
|
isolated = {'tenant_id':'default','agent_id':'memory_test','session_id':'c','context':{'business_context':{'customer_key':'MEM-002'}}}
|
||||||
|
assert await manager.load(isolated) == []
|
||||||
|
print('OK: persistência, recuperação entre sessões e isolamento validados')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
27
agent_framework_oci/docs/EXTERNAL_GUARDRAILS_JUDGES.md
Normal file
27
agent_framework_oci/docs/EXTERNAL_GUARDRAILS_JUDGES.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# External Guardrails and Judges SPI
|
||||||
|
|
||||||
|
`agent_framework_oci` supports agent-owned guardrails and judges without importing domain code into the core.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
output:
|
||||||
|
- code: ACME_POLICY
|
||||||
|
type: external
|
||||||
|
class: app.extensions.guardrails:AcmePolicyRail
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
judges:
|
||||||
|
- name: acme_quality
|
||||||
|
type: external
|
||||||
|
class: app.extensions.judges:AcmeQualityJudge
|
||||||
|
threshold: 0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Native entries remain unchanged. External synchronous `evaluate()` methods execute in worker threads via `asyncio.to_thread`; asynchronous methods execute concurrently on the framework event loop. Judges run concurrently with `asyncio.gather`, preserving YAML result order. Agent plugins should reuse the LLM supplied by the framework rather than instantiate a separate provider.
|
||||||
|
|
||||||
|
The core must not reference a concrete agent package, company, product, telecom identifier or domain-specific policy. Domain-specific variants belong to the agent and should receive distinct public codes/names.
|
||||||
|
|
||||||
|
## Compatibility rule
|
||||||
|
Domain policies must not be replaced by cosmetically generic text inside the core while losing the original policy. The generic core implementation and the agent-specific implementation may coexist; the embedding agent explicitly selects its own code/name in YAML.
|
||||||
|
|
||||||
|
Legacy business validators should migrate to the agent domain. A temporary compatibility shim is acceptable for old imports, but new application code must import the agent-owned implementation.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__all__ = ['settings']
|
||||||
|
from .config.settings import settings
|
||||||
|
|
||||||
|
from .idempotency import IdempotencyStore, InMemoryIdempotencyStore, create_idempotency_store
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
|
||||||
|
from .composite_publisher import CompositeAnalyticsPublisher
|
||||||
|
from .event_builder import build_analytics_event
|
||||||
|
from .factory import create_analytics_publisher
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AnalyticsPublisher",
|
||||||
|
"NoopAnalyticsPublisher",
|
||||||
|
"CompositeAnalyticsPublisher",
|
||||||
|
"build_analytics_event",
|
||||||
|
"create_analytics_publisher",
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from .publisher import AnalyticsPublisher
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics.composite")
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Publica o mesmo evento em múltiplos destinos.
|
||||||
|
|
||||||
|
Use para rodar OCI Streaming e Pub/Sub em paralelo durante transição,
|
||||||
|
homologação ou estratégia multi-cloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, publishers: Iterable[AnalyticsPublisher], *, fail_silent: bool = True):
|
||||||
|
self.publishers = list(publishers)
|
||||||
|
self.fail_silent = fail_silent
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
if not self.publishers:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _safe_publish(publisher: AnalyticsPublisher) -> None:
|
||||||
|
try:
|
||||||
|
await publisher.publish(event_type, payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("analytics.publisher_failed provider=%s event_type=%s", publisher.__class__.__name__, event_type)
|
||||||
|
if not self.fail_silent:
|
||||||
|
raise
|
||||||
|
|
||||||
|
await asyncio.gather(*[_safe_publish(p) for p in self.publishers])
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_analytics_event(
|
||||||
|
event_type: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
source: str = "agent_framework",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Monta envelope uniforme para IC/NOC/GRL.
|
||||||
|
|
||||||
|
O campo metadata.noc=true é preservado para que o Observer consiga rotear
|
||||||
|
eventos também para NOC/OTEL/Elastic quando aplicável.
|
||||||
|
"""
|
||||||
|
body = dict(payload or {})
|
||||||
|
meta = dict(metadata or {})
|
||||||
|
return {
|
||||||
|
"eventType": event_type,
|
||||||
|
"source": source,
|
||||||
|
"eventDate": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"payload": body,
|
||||||
|
"metadata": meta,
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .composite_publisher import CompositeAnalyticsPublisher
|
||||||
|
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics.factory")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: str | None) -> list[str]:
|
||||||
|
return [item.strip().lower() for item in (value or "").split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def create_analytics_publisher(settings: Any | None = None) -> AnalyticsPublisher:
|
||||||
|
"""Cria publisher conforme env/config.
|
||||||
|
|
||||||
|
Variáveis novas compatíveis:
|
||||||
|
- ENABLE_ANALYTICS=true|false
|
||||||
|
- ANALYTICS_PROVIDERS=oci_streaming,pubsub
|
||||||
|
- GCP_PUBSUB_TOPIC_PATH=projects/.../topics/...
|
||||||
|
- AGENT_PUBSUB_TOPIC=projects/.../topics/... # compatibilidade FIRST/TIM
|
||||||
|
- GCP_PROJECT_ID=... + GCP_PUBSUB_TOPIC=...
|
||||||
|
"""
|
||||||
|
if settings is None:
|
||||||
|
from agent_framework.config.settings import settings as default_settings
|
||||||
|
settings = default_settings
|
||||||
|
|
||||||
|
analytics_enabled = bool(getattr(settings, "ENABLE_ANALYTICS", False))
|
||||||
|
langfuse_enabled = bool(getattr(settings, "ENABLE_LANGFUSE", False))
|
||||||
|
|
||||||
|
# Historicamente o observer era usado para enviar IC/NOC/GRL ao Langfuse
|
||||||
|
# mesmo quando o pipeline de analytics/streaming não estava habilitado.
|
||||||
|
# Portanto, ENABLE_LANGFUSE=true também ativa o publisher Langfuse do observer.
|
||||||
|
if not analytics_enabled and not langfuse_enabled:
|
||||||
|
return NoopAnalyticsPublisher()
|
||||||
|
|
||||||
|
providers = _split_csv(getattr(settings, "ANALYTICS_PROVIDERS", "")) or ["oci_streaming"]
|
||||||
|
if langfuse_enabled and "langfuse" not in providers:
|
||||||
|
providers.insert(0, "langfuse")
|
||||||
|
|
||||||
|
# Se analytics geral estiver desligado, publica somente no Langfuse para
|
||||||
|
# evitar inicializar OCI Streaming/PubSub por engano em ambientes locais.
|
||||||
|
if not analytics_enabled:
|
||||||
|
providers = [p for p in providers if p in {"langfuse", "noop", "none"}] or ["langfuse"]
|
||||||
|
publishers: list[AnalyticsPublisher] = []
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
try:
|
||||||
|
if provider == "langfuse":
|
||||||
|
from .providers.langfuse import LangfuseAnalyticsPublisher
|
||||||
|
publishers.append(LangfuseAnalyticsPublisher(settings=settings))
|
||||||
|
elif provider == "oci_streaming":
|
||||||
|
from .providers.oci_streaming import OCIStreamingAnalyticsPublisher
|
||||||
|
publishers.append(OCIStreamingAnalyticsPublisher(settings=settings))
|
||||||
|
elif provider in {"pubsub", "gcp_pubsub", "gcp"}:
|
||||||
|
from .providers.pubsub import PubSubAnalyticsPublisher
|
||||||
|
topic = (
|
||||||
|
getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None)
|
||||||
|
or getattr(settings, "AGENT_PUBSUB_TOPIC", None)
|
||||||
|
)
|
||||||
|
publishers.append(PubSubAnalyticsPublisher(topic_path=topic))
|
||||||
|
elif provider in {"noop", "none"}:
|
||||||
|
publishers.append(NoopAnalyticsPublisher())
|
||||||
|
else:
|
||||||
|
logger.warning("analytics.provider_ignored provider=%s", provider)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("analytics.provider_init_failed provider=%s", provider)
|
||||||
|
|
||||||
|
if not publishers:
|
||||||
|
# Sem este log, "analytics ligado mas todos os providers falharam" fica
|
||||||
|
# indistinguivel de "analytics desligado": o publisher no-op descarta
|
||||||
|
# IC/NOC/GRL em silencio ate o processo ser reiniciado.
|
||||||
|
logger.error(
|
||||||
|
"analytics.no_publisher_available providers=%s enable_analytics=%s "
|
||||||
|
"enable_langfuse=%s; telemetria sera descartada ate o proximo restart",
|
||||||
|
",".join(providers),
|
||||||
|
analytics_enabled,
|
||||||
|
langfuse_enabled,
|
||||||
|
)
|
||||||
|
return NoopAnalyticsPublisher()
|
||||||
|
if len(publishers) == 1:
|
||||||
|
return publishers[0]
|
||||||
|
return CompositeAnalyticsPublisher(publishers)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from .oci_streaming import OCIStreamingAnalyticsPublisher
|
||||||
|
from .pubsub import PubSubAnalyticsPublisher
|
||||||
|
from .kafka import KafkaAnalyticsPublisher
|
||||||
|
from .langfuse import LangfuseAnalyticsPublisher
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OCIStreamingAnalyticsPublisher",
|
||||||
|
"PubSubAnalyticsPublisher",
|
||||||
|
"KafkaAnalyticsPublisher",
|
||||||
|
"LangfuseAnalyticsPublisher",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||||
|
|
||||||
|
|
||||||
|
class KafkaAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Publisher Kafka opcional.
|
||||||
|
|
||||||
|
Recebe um producer já criado para não acoplar o framework a uma lib específica
|
||||||
|
(confluent-kafka, aiokafka, kafka-python etc.). O producer precisa expor send
|
||||||
|
assíncrono ou síncrono.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, producer: Any, topic: str):
|
||||||
|
self.producer = producer
|
||||||
|
self.topic = topic
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
message = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
|
||||||
|
result = self.producer.send(self.topic, key=event_type.encode("utf-8"), value=message)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||||
|
from agent_framework.observability.code_mapper import create_observability_code_mapper
|
||||||
|
|
||||||
|
try: # Avoid making analytics import fragile in old deployments.
|
||||||
|
from agent_framework.observability.context import get_current_observation_id, get_observability_context
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
get_observability_context = None # type: ignore
|
||||||
|
get_current_observation_id = None # type: ignore
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics.langfuse")
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: Any, default: bool = False) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_metadata(value: Any) -> Any:
|
||||||
|
"""Remove/mascara segredos antes de enviar metadata para Langfuse."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
lk = str(key).lower()
|
||||||
|
if any(token in lk for token in ("password", "secret", "token", "api_key", "authorization")):
|
||||||
|
out[key] = "***"
|
||||||
|
else:
|
||||||
|
out[key] = _safe_metadata(item)
|
||||||
|
return out
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_safe_metadata(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_LANGFUSE_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
_INTERNAL_PREFIXES = ("IC.", "AGA.", "NOC.", "GRL.")
|
||||||
|
_TECHNICAL_PREFIXES = (
|
||||||
|
"langgraph.",
|
||||||
|
"mcp.",
|
||||||
|
"guardrail.",
|
||||||
|
"judge.",
|
||||||
|
"workflow.",
|
||||||
|
"rag.",
|
||||||
|
"cache.",
|
||||||
|
"checkpoint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_str(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _first(*values: Any) -> str | None:
|
||||||
|
for value in values:
|
||||||
|
text = _clean_str(value)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _current_context() -> dict[str, Any]:
|
||||||
|
if get_observability_context is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return get_observability_context().clean()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_parent_observation_id() -> str | None:
|
||||||
|
if get_current_observation_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = get_current_observation_id()
|
||||||
|
return str(value) if value else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_name(name: Any) -> bool:
|
||||||
|
text = _clean_str(name) or ""
|
||||||
|
return text.startswith(_INTERNAL_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_technical_name(name: Any) -> bool:
|
||||||
|
text = _clean_str(name) or ""
|
||||||
|
return text.startswith(_TECHNICAL_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_control_or_technical(name: Any) -> bool:
|
||||||
|
return _is_internal_name(name) or _is_technical_name(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_envelope_event_type(envelope: dict[str, Any]) -> str | None:
|
||||||
|
return _first(
|
||||||
|
envelope.get("eventType"),
|
||||||
|
envelope.get("event_type"),
|
||||||
|
envelope.get("name"),
|
||||||
|
envelope.get("type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wrapped_internal_event(event_type: str, envelope: dict[str, Any]) -> bool:
|
||||||
|
"""Detecta caso que gerava trace raiz errado.
|
||||||
|
|
||||||
|
Exemplo observado no Langfuse:
|
||||||
|
name=http.request.completed
|
||||||
|
input={"eventType": "NOC.006", ...}
|
||||||
|
output={"published": true}
|
||||||
|
|
||||||
|
Isso não é o trace real da request; é apenas o publisher de analytics
|
||||||
|
emitindo um envelope IC/NOC/GRL através de um evento técnico. Esse registro
|
||||||
|
deve ser suprimido para não poluir a tela Tracing -> Traces.
|
||||||
|
"""
|
||||||
|
envelope_event_type = _extract_envelope_event_type(envelope)
|
||||||
|
return bool(
|
||||||
|
envelope_event_type
|
||||||
|
and _is_internal_name(envelope_event_type)
|
||||||
|
and str(event_type) != envelope_event_type
|
||||||
|
and str(event_type).startswith(("http.request.", "gateway.", "telemetry."))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_correlation_id(metadata: dict[str, Any]) -> str | None:
|
||||||
|
# IMPORTANT: prefer request/trace ids over transaction/session ids. Using
|
||||||
|
# transaction/session as first choice created duplicate root traces for
|
||||||
|
# IC/NOC/GRL events while the HTTP trace used request_id.
|
||||||
|
value = (
|
||||||
|
metadata.get("traceId")
|
||||||
|
or metadata.get("trace_id")
|
||||||
|
or metadata.get("requestId")
|
||||||
|
or metadata.get("request_id")
|
||||||
|
or metadata.get("transactionId")
|
||||||
|
or metadata.get("transaction_id")
|
||||||
|
or metadata.get("sessionId")
|
||||||
|
or metadata.get("session_id")
|
||||||
|
)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _langfuse_trace_id(value: Any) -> str | None:
|
||||||
|
"""Normaliza ids do framework/business para o formato aceito pelo Langfuse.
|
||||||
|
|
||||||
|
Langfuse SDK v3 exige 32 caracteres hex minúsculos. UUIDs com hífens são
|
||||||
|
compactados; ids de negócio/sessão viram hash md5 determinístico.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
raw = str(value).strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
compact = raw.replace("-", "")
|
||||||
|
if _LANGFUSE_TRACE_ID_RE.match(compact):
|
||||||
|
return compact
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _correlation_trace_id(metadata: dict[str, Any]) -> str | None:
|
||||||
|
return _langfuse_trace_id(_raw_correlation_id(metadata))
|
||||||
|
|
||||||
|
|
||||||
|
def _with_trace_context(kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw_id = _raw_correlation_id(metadata)
|
||||||
|
trace_id = _langfuse_trace_id(raw_id)
|
||||||
|
parent_id = (
|
||||||
|
metadata.get("parent_observation_id")
|
||||||
|
or metadata.get("parent_span_id")
|
||||||
|
or kwargs.get("parent_observation_id")
|
||||||
|
or kwargs.get("parent_span_id")
|
||||||
|
or _current_parent_observation_id()
|
||||||
|
)
|
||||||
|
if trace_id:
|
||||||
|
trace_context = dict(kwargs.get("trace_context") or {})
|
||||||
|
trace_context.setdefault("trace_id", trace_id)
|
||||||
|
if parent_id:
|
||||||
|
trace_context.setdefault("parent_span_id", str(parent_id))
|
||||||
|
kwargs["trace_context"] = trace_context
|
||||||
|
meta = kwargs.setdefault("metadata", {})
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
meta.setdefault("framework_trace_id", raw_id)
|
||||||
|
meta.setdefault("langfuse_trace_id", trace_id)
|
||||||
|
if parent_id:
|
||||||
|
meta.setdefault("parent_observation_id", str(parent_id))
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_standalone_internal_events() -> bool:
|
||||||
|
# Default false: IC/NOC/GRL sem contexto de request não devem criar linhas
|
||||||
|
# soltas na tela principal de Traces. Habilite só para debug isolado.
|
||||||
|
return _truthy(os.getenv("LANGFUSE_ALLOW_STANDALONE_INTERNAL_EVENTS"), False)
|
||||||
|
|
||||||
|
|
||||||
|
class LangfuseAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Publica eventos IC/NOC/GRL no Langfuse sem criar traces raiz duplicados.
|
||||||
|
|
||||||
|
Regra principal:
|
||||||
|
- 1 request/workflow = 1 trace raiz;
|
||||||
|
- IC/NOC/GRL e eventos técnicos entram como observations/spans dentro do
|
||||||
|
trace corrente;
|
||||||
|
- envelopes internos embrulhados em eventos HTTP/gateway não criam trace
|
||||||
|
próprio com output {"published": true}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Any | None = None, langfuse: Any | None = None):
|
||||||
|
if settings is None:
|
||||||
|
from agent_framework.config.settings import settings as default_settings
|
||||||
|
settings = default_settings
|
||||||
|
|
||||||
|
self.settings = settings
|
||||||
|
self.code_mapper = create_observability_code_mapper(settings)
|
||||||
|
self.langfuse = langfuse
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
if self.langfuse is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
public_key = getattr(settings, "LANGFUSE_PUBLIC_KEY", None) or os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||||
|
secret_key = getattr(settings, "LANGFUSE_SECRET_KEY", None) or os.getenv("LANGFUSE_SECRET_KEY")
|
||||||
|
host = getattr(settings, "LANGFUSE_HOST", None) or os.getenv("LANGFUSE_HOST") or "https://cloud.langfuse.com"
|
||||||
|
|
||||||
|
if not public_key or not secret_key:
|
||||||
|
self.enabled = False
|
||||||
|
logger.warning("LangfuseAnalyticsPublisher desabilitado: LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY ausentes")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from langfuse import Langfuse # type: ignore
|
||||||
|
self.langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
|
||||||
|
logger.info("LangfuseAnalyticsPublisher habilitado host=%s", host)
|
||||||
|
except Exception:
|
||||||
|
self.enabled = False
|
||||||
|
self.langfuse = None
|
||||||
|
logger.exception("Falha ao inicializar LangfuseAnalyticsPublisher")
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
if not self.enabled or self.langfuse is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
event_type = str(event_type)
|
||||||
|
envelope = dict(payload or {})
|
||||||
|
|
||||||
|
# Prevent the exact pollution seen in Langfuse: http.request.completed
|
||||||
|
# traces whose input is a NOC/IC envelope and output is {published:true}.
|
||||||
|
if _is_wrapped_internal_event(event_type, envelope):
|
||||||
|
logger.debug(
|
||||||
|
"langfuse.analytics.skip_wrapped_internal event_type=%s envelope_event_type=%s",
|
||||||
|
event_type,
|
||||||
|
_extract_envelope_event_type(envelope),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
body = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
|
||||||
|
metadata = envelope.get("metadata") if isinstance(envelope.get("metadata"), dict) else {}
|
||||||
|
ctx = _current_context()
|
||||||
|
|
||||||
|
source = envelope.get("source") or "agent_framework"
|
||||||
|
event_date = envelope.get("eventDate")
|
||||||
|
envelope_event_type = _extract_envelope_event_type(envelope)
|
||||||
|
effective_event_type = envelope_event_type if _is_internal_name(envelope_event_type) else event_type
|
||||||
|
|
||||||
|
# LangfuseAnalyticsPublisher talks directly to the Langfuse SDK and does
|
||||||
|
# not pass through Telemetry._start_observation(). Apply the same contract
|
||||||
|
# mapper here so analytics observations cannot leak internal names.
|
||||||
|
original_effective_event_type = str(effective_event_type)
|
||||||
|
effective_event_type, mapping_meta = self.code_mapper.normalize_name(
|
||||||
|
original_effective_event_type,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
if mapping_meta != metadata:
|
||||||
|
metadata = mapping_meta
|
||||||
|
if isinstance(envelope.get("metadata"), dict):
|
||||||
|
envelope["metadata"] = dict(mapping_meta)
|
||||||
|
|
||||||
|
# Correlation priority: current ObservabilityContext > payload metadata >
|
||||||
|
# transaction/session fallback. This keeps IC/NOC/GRL in the same HTTP trace.
|
||||||
|
correlation_request_id = _first(
|
||||||
|
ctx.get("request_id"),
|
||||||
|
ctx.get("trace_id"),
|
||||||
|
body.get("request_id"), metadata.get("request_id"),
|
||||||
|
body.get("requestId"), metadata.get("requestId"),
|
||||||
|
envelope.get("request_id"), envelope.get("requestId"),
|
||||||
|
)
|
||||||
|
correlation_trace_id = _first(
|
||||||
|
ctx.get("trace_id"),
|
||||||
|
ctx.get("request_id"),
|
||||||
|
body.get("trace_id"), metadata.get("trace_id"),
|
||||||
|
body.get("traceId"), metadata.get("traceId"),
|
||||||
|
correlation_request_id,
|
||||||
|
)
|
||||||
|
correlation_session_id = _first(
|
||||||
|
ctx.get("session_id"),
|
||||||
|
body.get("session_id"), metadata.get("session_id"),
|
||||||
|
body.get("sessionId"), metadata.get("sessionId"),
|
||||||
|
body.get("transaction_id"), metadata.get("transaction_id"),
|
||||||
|
body.get("transactionId"), metadata.get("transactionId"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is_internal = _is_internal_name(effective_event_type)
|
||||||
|
is_technical = _is_technical_name(effective_event_type)
|
||||||
|
|
||||||
|
# IC/NOC/GRL without current/request correlation are usually emitted by
|
||||||
|
# background/legacy publishers. Do not create standalone trace rows unless
|
||||||
|
# explicitly requested for debugging.
|
||||||
|
if (is_internal or is_technical) and not correlation_trace_id and not _allow_standalone_internal_events():
|
||||||
|
logger.debug("langfuse.analytics.skip_unrelated_internal event_type=%s", effective_event_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
langfuse_metadata = _safe_metadata({
|
||||||
|
"eventType": effective_event_type,
|
||||||
|
"observability_name_internal": mapping_meta.get("observability_name_internal"),
|
||||||
|
"observability_name_mapped": mapping_meta.get("observability_name_mapped"),
|
||||||
|
"observability_code_mapped": mapping_meta.get("observability_code_mapped"),
|
||||||
|
"original_event_type": original_effective_event_type if original_effective_event_type != effective_event_type else (event_type if event_type != effective_event_type else None),
|
||||||
|
"source": source,
|
||||||
|
"eventDate": event_date,
|
||||||
|
"payload": body,
|
||||||
|
"metadata": metadata,
|
||||||
|
"ic": _is_ic(str(effective_event_type), metadata),
|
||||||
|
"noc": _is_noc(str(effective_event_type), metadata),
|
||||||
|
"grl": _is_grl(str(effective_event_type), metadata),
|
||||||
|
"tag": body.get("tag") or metadata.get("tag") or effective_event_type,
|
||||||
|
"request_id": correlation_request_id,
|
||||||
|
"trace_id": correlation_trace_id,
|
||||||
|
"transaction_id": body.get("transaction_id") or metadata.get("transaction_id") or body.get("transactionId") or metadata.get("transactionId"),
|
||||||
|
"sessionId": correlation_session_id,
|
||||||
|
"session_id": correlation_session_id,
|
||||||
|
"messageId": body.get("messageId") or metadata.get("messageId") or body.get("message_id") or metadata.get("message_id") or ctx.get("message_id"),
|
||||||
|
"agentId": body.get("agentId") or metadata.get("agentId") or body.get("agent_id") or metadata.get("agent_id") or ctx.get("agent_id"),
|
||||||
|
"channelId": body.get("channelId") or metadata.get("channelId") or body.get("channel") or metadata.get("channel") or ctx.get("channel"),
|
||||||
|
"workflow_id": body.get("workflow_id") or metadata.get("workflow_id") or ctx.get("workflow_id"),
|
||||||
|
"tenant_id": body.get("tenant_id") or metadata.get("tenant_id") or ctx.get("tenant_id"),
|
||||||
|
"parent_observation_id": body.get("parent_observation_id") or metadata.get("parent_observation_id") or _current_parent_observation_id(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Keep correlation metadata on the trace, but do not turn every control
|
||||||
|
# event code into a trace tag. IC/NOC/GRL are represented by the child
|
||||||
|
# observation below; tags are not a substitute for the event span and
|
||||||
|
# high-cardinality event-code tags make the trace harder to inspect.
|
||||||
|
self._update_current_trace(langfuse_metadata)
|
||||||
|
|
||||||
|
# Prefer current/correlated observation API. For internal/technical events,
|
||||||
|
# do not fall back to standalone span/trace APIs if this fails.
|
||||||
|
try:
|
||||||
|
if hasattr(self.langfuse, "start_as_current_observation"):
|
||||||
|
kwargs = {
|
||||||
|
"name": str(effective_event_type),
|
||||||
|
"as_type": "span",
|
||||||
|
"input": envelope,
|
||||||
|
"metadata": langfuse_metadata,
|
||||||
|
}
|
||||||
|
# trace_context rebuilds the parent as a remote span (SDK cross-process
|
||||||
|
# propagation); skip it when a real span is already active locally.
|
||||||
|
if not _current_parent_observation_id():
|
||||||
|
kwargs = _with_trace_context(kwargs, langfuse_metadata)
|
||||||
|
try:
|
||||||
|
cm = self.langfuse.start_as_current_observation(**kwargs)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
kwargs.pop("trace_context", None)
|
||||||
|
cm = self.langfuse.start_as_current_observation(**kwargs)
|
||||||
|
with cm as observation:
|
||||||
|
_update_observation(observation, output={"published": True})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
log = logger.warning if is_internal else logger.debug
|
||||||
|
log("Falha ao publicar Langfuse observation para %s", effective_event_type, exc_info=True)
|
||||||
|
if is_internal or is_technical:
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_internal or is_technical:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Legacy fallbacks only for non-internal, high-level events.
|
||||||
|
try:
|
||||||
|
trace_id = _correlation_trace_id(langfuse_metadata)
|
||||||
|
if trace_id and hasattr(self.langfuse, "trace"):
|
||||||
|
trace = self.langfuse.trace(
|
||||||
|
id=str(trace_id),
|
||||||
|
name=str(langfuse_metadata.get("request_id") or langfuse_metadata.get("sessionId") or "agent_framework.request"),
|
||||||
|
session_id=langfuse_metadata.get("sessionId"),
|
||||||
|
user_id=langfuse_metadata.get("user_id") or langfuse_metadata.get("userId"),
|
||||||
|
metadata={k: v for k, v in langfuse_metadata.items() if v is not None},
|
||||||
|
)
|
||||||
|
if hasattr(trace, "span"):
|
||||||
|
span = trace.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
|
||||||
|
if hasattr(span, "end"):
|
||||||
|
span.end(output={"published": True})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Falha ao publicar Langfuse span correlacionado para %s", effective_event_type, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(self.langfuse, "span"):
|
||||||
|
span = self.langfuse.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
|
||||||
|
if hasattr(span, "end"):
|
||||||
|
span.end(output={"published": True})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Falha ao publicar Langfuse span legado para %s", effective_event_type, exc_info=True)
|
||||||
|
|
||||||
|
def _update_current_trace(self, metadata: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"metadata": {k: v for k, v in metadata.items() if v is not None},
|
||||||
|
}
|
||||||
|
session_id = metadata.get("sessionId") or metadata.get("session_id")
|
||||||
|
if session_id:
|
||||||
|
kwargs["session_id"] = str(session_id)
|
||||||
|
if hasattr(self.langfuse, "update_current_trace"):
|
||||||
|
self.langfuse.update_current_trace(**kwargs)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Langfuse update_current_trace ignorado", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_observation(observation: Any, **kwargs: Any) -> None:
|
||||||
|
if observation is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if hasattr(observation, "update"):
|
||||||
|
observation.update(**{k: v for k, v in kwargs.items() if v is not None})
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Langfuse observation update ignorado", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noc(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||||
|
return event_type.startswith("NOC.") or _truthy(metadata.get("noc"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_grl(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||||
|
return event_type.startswith("GRL.") or _truthy(metadata.get("grl"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ic(event_type: str, metadata: dict[str, Any]) -> bool:
|
||||||
|
return event_type.startswith(("IC.", "AGA.")) or _truthy(metadata.get("ic"))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||||
|
from agent_framework.analytics.tim_sequence import ensure_sequence_envelope
|
||||||
|
|
||||||
|
|
||||||
|
class OCIStreamingAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Adapter para reutilizar o publisher OCI Streaming existente do framework."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Any | None = None, event_publisher: Any | None = None):
|
||||||
|
if event_publisher is not None:
|
||||||
|
self.event_publisher = event_publisher
|
||||||
|
else:
|
||||||
|
from agent_framework.config.settings import settings as default_settings
|
||||||
|
from agent_framework.events.oci_streaming import create_event_publisher
|
||||||
|
self.event_publisher = create_event_publisher(settings or default_settings)
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
# Carimba o contador de sequence no envelope antes do publish, espelhando o
|
||||||
|
# PubSubAnalyticsPublisher. Sem isto o path OCI Streaming sai sem sequence
|
||||||
|
# (a geração estava amarrada apenas ao Pub/Sub na migração do framework).
|
||||||
|
# ensure_sequence_envelope não quebra observabilidade: se faltar sessionId
|
||||||
|
# ou o backend do contador falhar, o evento segue sem o campo.
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = await ensure_sequence_envelope(payload)
|
||||||
|
await self.event_publisher.publish(event_type, payload)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.analytics.tim_payload_mapper import map_analytics_event_to_tim_flat_payload
|
||||||
|
from agent_framework.analytics.tim_sequence import ensure_sequence
|
||||||
|
|
||||||
|
from agent_framework.analytics.publisher import AnalyticsPublisher
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics.pubsub")
|
||||||
|
|
||||||
|
|
||||||
|
class PubSubAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Publisher GCP Pub/Sub real, compatível com FIRST/TIM.
|
||||||
|
|
||||||
|
Formas aceitas de configuração:
|
||||||
|
|
||||||
|
1. GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-id>
|
||||||
|
2. AGENT_PUBSUB_TOPIC=projects/<project-id>/topics/<topic-id>
|
||||||
|
3. GCP_PROJECT_ID=<project-id> + GCP_PUBSUB_TOPIC=<topic-id>
|
||||||
|
|
||||||
|
Credenciais seguem o padrão Google:
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=/secrets/service-account.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
topic_path: str | None = None,
|
||||||
|
*,
|
||||||
|
project_id: str | None = None,
|
||||||
|
topic_id: str | None = None,
|
||||||
|
ordering_key: str | None = None,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
):
|
||||||
|
self.topic_path = self._resolve_topic_path(topic_path, project_id=project_id, topic_id=topic_id)
|
||||||
|
self.ordering_key = ordering_key or os.getenv("GCP_PUBSUB_ORDERING_KEY") or ""
|
||||||
|
self.timeout_seconds = float(timeout_seconds or os.getenv("GCP_PUBSUB_TIMEOUT_SECONDS") or 30)
|
||||||
|
self.payload_mode = (os.getenv("PUBSUB_PAYLOAD_MODE") or os.getenv("ANALYTICS_PUBSUB_PAYLOAD_MODE") or "flat").strip().lower()
|
||||||
|
self.exclude_noc = (os.getenv("PUBSUB_EXCLUDE_NOC") or "true").strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||||
|
self.excluded_event_types = {
|
||||||
|
item.strip().upper()
|
||||||
|
for item in os.getenv("PUBSUB_EXCLUDED_EVENT_TYPES", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
from google.cloud import pubsub_v1 # type: ignore
|
||||||
|
|
||||||
|
self.client = pubsub_v1.PublisherClient()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_topic_path(topic_path: str | None, *, project_id: str | None, topic_id: str | None) -> str:
|
||||||
|
explicit = (
|
||||||
|
topic_path
|
||||||
|
or os.getenv("GCP_PUBSUB_TOPIC_PATH")
|
||||||
|
or os.getenv("AGENT_PUBSUB_TOPIC")
|
||||||
|
or os.getenv("PUBSUB_TOPIC_PATH")
|
||||||
|
)
|
||||||
|
if explicit:
|
||||||
|
explicit = explicit.strip()
|
||||||
|
if explicit.startswith("projects/"):
|
||||||
|
return explicit
|
||||||
|
# Permite passar só o nome do tópico quando project_id estiver disponível.
|
||||||
|
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||||
|
if project:
|
||||||
|
return f"projects/{project}/topics/{explicit}"
|
||||||
|
raise ValueError("topic_path deve estar no formato projects/<project-id>/topics/<topic-id> quando GCP_PROJECT_ID não está definido")
|
||||||
|
|
||||||
|
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||||
|
topic = topic_id or os.getenv("GCP_PUBSUB_TOPIC") or os.getenv("PUBSUB_TOPIC")
|
||||||
|
if project and topic:
|
||||||
|
return f"projects/{project}/topics/{topic}"
|
||||||
|
|
||||||
|
raise ValueError("Configure GCP_PUBSUB_TOPIC_PATH, AGENT_PUBSUB_TOPIC ou GCP_PROJECT_ID + GCP_PUBSUB_TOPIC")
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
event_key = str(event_type).upper()
|
||||||
|
if event_key in self.excluded_event_types:
|
||||||
|
logger.debug("analytics.pubsub.skipped_event event_type=%s", event_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata = payload.get("metadata") if isinstance(payload, dict) else None
|
||||||
|
is_noc = str(event_type).startswith("NOC.") or (isinstance(metadata, dict) and metadata.get("noc") is True)
|
||||||
|
if is_noc and self.exclude_noc:
|
||||||
|
logger.debug("analytics.pubsub.skipped_noc event_type=%s", event_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.payload_mode in {"legacy", "envelope", "wrapped"}:
|
||||||
|
message = {"type": event_type, "payload": payload}
|
||||||
|
else:
|
||||||
|
message = map_analytics_event_to_tim_flat_payload(event_type, payload, keep_none=False)
|
||||||
|
message = await ensure_sequence(message)
|
||||||
|
|
||||||
|
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
|
||||||
|
attributes = {
|
||||||
|
"event_type": str(event_type),
|
||||||
|
"source": str(payload.get("source") or "agent_framework"),
|
||||||
|
}
|
||||||
|
if is_noc:
|
||||||
|
attributes["noc"] = "true"
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = dict(attributes)
|
||||||
|
if self.ordering_key:
|
||||||
|
kwargs["ordering_key"] = self.ordering_key
|
||||||
|
|
||||||
|
future = self.client.publish(self.topic_path, data=data, **kwargs)
|
||||||
|
await asyncio.to_thread(future.result, timeout=self.timeout_seconds)
|
||||||
|
logger.debug("analytics.pubsub.published event_type=%s topic=%s", event_type, self.topic_path)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics")
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsPublisher(ABC):
|
||||||
|
"""Contrato único para eventos analíticos corporativos.
|
||||||
|
|
||||||
|
A intenção é desacoplar o agente de OCI Streaming, GCP Pub/Sub, Kafka,
|
||||||
|
BigQuery ou qualquer outro destino. Os agentes publicam eventos de negócio
|
||||||
|
ou operação usando apenas este contrato.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class NoopAnalyticsPublisher(AnalyticsPublisher):
|
||||||
|
"""Publisher seguro para ambientes locais/testes."""
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
logger.info("analytics.noop event_type=%s payload_keys=%s", event_type, sorted(payload.keys()))
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _first(mapping: dict[str, Any], *keys: str) -> Any:
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping and mapping.get(key) is not None:
|
||||||
|
return mapping.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (tuple, set)):
|
||||||
|
return list(value)
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_agent_specific_data(metadata: dict[str, Any], body: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
prefixed: dict[str, Any] = {}
|
||||||
|
for source in (metadata, body):
|
||||||
|
for key, value in source.items():
|
||||||
|
if key.startswith("agentSpecificData."):
|
||||||
|
prefixed[key.removeprefix("agentSpecificData.")] = value
|
||||||
|
if prefixed:
|
||||||
|
return prefixed
|
||||||
|
|
||||||
|
direct = _first(metadata, "agentSpecificData")
|
||||||
|
if isinstance(direct, dict):
|
||||||
|
return dict(direct)
|
||||||
|
if isinstance(direct, str) and direct.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(direct)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
direct = _first(body, "agentSpecificData")
|
||||||
|
if isinstance(direct, dict):
|
||||||
|
return dict(direct)
|
||||||
|
if isinstance(direct, str) and direct.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(direct)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def map_analytics_event_to_tim_flat_payload(
|
||||||
|
event_type: str,
|
||||||
|
event: dict[str, Any],
|
||||||
|
*,
|
||||||
|
keep_none: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Map the framework analytics envelope to TIM's flat Pub/Sub/NOC schema.
|
||||||
|
|
||||||
|
The canonical fields are published at the JSON root. The only intentional
|
||||||
|
nested object is ``agentSpecificData``.
|
||||||
|
"""
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
event = {}
|
||||||
|
|
||||||
|
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||||
|
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
|
||||||
|
data: dict[str, Any] = {**body, **metadata}
|
||||||
|
|
||||||
|
token_usage = event.get("token_usage") if isinstance(event.get("token_usage"), dict) else {}
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
# Tracking
|
||||||
|
"eventType": event.get("eventType") or event_type,
|
||||||
|
"traceId": _first(data, "traceId", "trace_id"),
|
||||||
|
"transactionId": _first(data, "transactionId", "transaction_id", "transactionID"),
|
||||||
|
"spanId": _first(data, "spanId", "span_id"),
|
||||||
|
"parentSpanId": _first(data, "parentSpanId", "parent_span_id"),
|
||||||
|
"eventName": _first(data, "eventName", "name"),
|
||||||
|
"version": _first(data, "version") or "1.0",
|
||||||
|
"eventDate": _first(data, "eventDate") or event.get("eventDate") or datetime.now(timezone.utc).isoformat(),
|
||||||
|
# Session/channel
|
||||||
|
"sessionId": _first(data, "sessionId", "session_id"),
|
||||||
|
"channelId": _first(data, "channelId", "channel", "channel_id"),
|
||||||
|
"agentId": _first(data, "agentId", "agent_id"),
|
||||||
|
"customerCode": _first(data, "customerCode", "customer_code"),
|
||||||
|
"touchpoint": _first(data, "touchpoint"),
|
||||||
|
"protocol": _first(data, "protocol"),
|
||||||
|
"tag": _first(data, "tag") or event.get("eventType") or event_type,
|
||||||
|
"noc": True if _first(data, "noc") is True else None,
|
||||||
|
# Protocol/session
|
||||||
|
"agentProtocolId": _first(data, "agentProtocolId", "agent_protocol_id"),
|
||||||
|
"adjustedProtocol": _first(data, "adjustedProtocol", "adjusted_protocol"),
|
||||||
|
"sessionCreatedAt": _first(data, "sessionCreatedAt", "session_created_at"),
|
||||||
|
"sessionEndAt": _first(data, "sessionEndAt", "session_end_at"),
|
||||||
|
# URA/voice
|
||||||
|
"uraCallId": _first(data, "uraCallId", "ura_call_id"),
|
||||||
|
"transcriptionId": _first(data, "transcriptionId", "transcription_id"),
|
||||||
|
"gsm": _first(data, "gsm"),
|
||||||
|
"ani": _first(data, "ani"),
|
||||||
|
"uraProtocolId": _first(data, "uraProtocolId", "ura_protocol_id"),
|
||||||
|
"uraLatency": _first(data, "uraLatency", "ura_latency"),
|
||||||
|
"uraResolution": _first(data, "uraResolution", "urResolution", "ura_resolution"),
|
||||||
|
"customerMessage": _first(data, "customerMessage", "customer_message"),
|
||||||
|
# Message/guardrails/analysis
|
||||||
|
"messageId": _first(data, "messageId", "message_id"),
|
||||||
|
"blockingGuardrailsOutput": _first(data, "blockingGuardrailsOutput", "blocking_guardrails_output"),
|
||||||
|
"blockingGuardrailsInput": _first(data, "blockingGuardrailsInput", "blocking_guardrails_input"),
|
||||||
|
"llmResponse": _first(data, "llmResponse", "llm_response"),
|
||||||
|
"alucinationScore": _first(data, "alucinationScore", "hallucinationScore", "alucination_score"),
|
||||||
|
"noMatchRag": _first(data, "noMatchRag", "no_match_rag"),
|
||||||
|
"promptLength": _first(data, "promptLength", "prompt_length"),
|
||||||
|
"intention": _first(data, "intention", "intent"),
|
||||||
|
"loop": _first(data, "loop"),
|
||||||
|
"inferredCsiScore": _first(data, "inferredCsiScore", "inferred_csi_score"),
|
||||||
|
"supervisorBlockReasons": _first(data, "supervisorBlockReasons", "supervisor_block_reasons"),
|
||||||
|
"resolution": _first(data, "resolution"),
|
||||||
|
"ConversationPrecision": _first(data, "ConversationPrecision", "conversationPrecision", "conversation_precision"),
|
||||||
|
# LLM metrics
|
||||||
|
"model": _first(data, "model") or event.get("model"),
|
||||||
|
"tokenInput": _first(token_usage, "input_tokens") or _first(data, "tokenInput", "input_tokens"),
|
||||||
|
"tokenOutput": _first(token_usage, "output_tokens") or _first(data, "tokenOutput", "output_tokens"),
|
||||||
|
"latencyMs": _first(data, "latencyMs", "duration_ms"),
|
||||||
|
"toxicityScore": _first(data, "toxicityScore", "toxicity_score"),
|
||||||
|
"nps": _first(data, "nps"),
|
||||||
|
"judgeScore": _first(data, "judgeScore", "judge_score"),
|
||||||
|
"accuracyScore": _first(data, "accuracyScore", "accuracy_score"),
|
||||||
|
"guardrails": _first(data, "guardrails"),
|
||||||
|
# RAG
|
||||||
|
"ragRetrievedDocuments": _as_list(_first(data, "documentsRetrieved", "ragRetrievedDocuments")),
|
||||||
|
"ragSelectedDocuments": _as_list(_first(data, "documentsSelected", "ragSelectedDocuments")),
|
||||||
|
# API
|
||||||
|
"apiUrl": _first(data, "apiUrl", "api_url"),
|
||||||
|
"apiStatusCode": _first(data, "httpStatusCode", "apiStatusCode", "http_status_code"),
|
||||||
|
"apiResponsePayload": _first(data, "apiResponsePayload", "api_response_payload"),
|
||||||
|
# I/O
|
||||||
|
"inputData": _first(data, "inputData", "input_data"),
|
||||||
|
"outputData": _first(data, "outputData", "output_data"),
|
||||||
|
# Business/status/sequence
|
||||||
|
"agentSpecificData": _collect_agent_specific_data(metadata, body),
|
||||||
|
"status": _first(data, "status"),
|
||||||
|
"sequence": _first(data, "sequence"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if keep_none:
|
||||||
|
return {k: ("" if v is None else v) for k, v in payload.items()}
|
||||||
|
return {k: v for k, v in payload.items() if v is not None}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.analytics.tim_sequence")
|
||||||
|
|
||||||
|
# In-process fallback. This is not cross-process/global, but keeps telemetry alive
|
||||||
|
# when the configured shared sequence backend is unavailable, matching the
|
||||||
|
# framework principle that observability must not break business execution.
|
||||||
|
_memory_lock = threading.Lock()
|
||||||
|
_memory_counters: dict[str, int] = defaultdict(int)
|
||||||
|
|
||||||
|
SequenceProvider = Literal["auto", "redis", "mongodb", "mongo", "memory", "none"]
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def sequence_enabled() -> bool:
|
||||||
|
return _env_bool("PUBSUB_SEQUENCE_ENABLED", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_provider() -> SequenceProvider:
|
||||||
|
raw = (os.getenv("PUBSUB_SEQUENCE_PROVIDER") or "auto").strip().lower()
|
||||||
|
if raw in {"mongo"}:
|
||||||
|
return "mongodb"
|
||||||
|
if raw in {"auto", "redis", "mongodb", "memory", "none"}:
|
||||||
|
return raw # type: ignore[return-value]
|
||||||
|
logger.warning("tim_sequence.invalid_provider provider=%s; using auto", raw)
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_url() -> str | None:
|
||||||
|
return os.getenv("PUBSUB_SEQUENCE_REDIS_URL") or os.getenv("REDIS_URL")
|
||||||
|
|
||||||
|
|
||||||
|
def _mongo_uri() -> str | None:
|
||||||
|
return (
|
||||||
|
os.getenv("PUBSUB_SEQUENCE_MONGODB_URI")
|
||||||
|
or os.getenv("MONGODB_URI")
|
||||||
|
or os.getenv("MONGO_URI")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mongo_database() -> str:
|
||||||
|
return (
|
||||||
|
os.getenv("PUBSUB_SEQUENCE_MONGODB_DATABASE")
|
||||||
|
or os.getenv("MONGODB_DATABASE")
|
||||||
|
or os.getenv("MONGO_DATABASE")
|
||||||
|
or "agent_platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_agent_name() -> str:
|
||||||
|
return _safe_part(os.getenv("AGENT_NAME") or "agent", "agent")
|
||||||
|
|
||||||
|
|
||||||
|
def _mongo_collection() -> str:
|
||||||
|
"""Return the shared MongoDB collection used by every event producer.
|
||||||
|
|
||||||
|
The collection must not vary by agent. A transaction can emit GRL, AGA,
|
||||||
|
NOC and other events from different components, and all of them must
|
||||||
|
increment the same counter document. Deployments may override the name,
|
||||||
|
but the configured value must be identical in every producer/pod.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
os.getenv("PUBSUB_SEQUENCE_MONGODB_COLLECTION")
|
||||||
|
or os.getenv("MONGODB_EVENT_COUNTERS_COLLECTION")
|
||||||
|
or os.getenv("EVENT_COUNTERS_COLLECTION")
|
||||||
|
or "observer_event_counters"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl_seconds() -> int:
|
||||||
|
raw = os.getenv("PUBSUB_SEQUENCE_TTL_SECONDS") or os.getenv("SESSION_TTL_SECONDS") or "86400"
|
||||||
|
try:
|
||||||
|
return max(0, int(raw))
|
||||||
|
except Exception:
|
||||||
|
return 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_enabled() -> bool:
|
||||||
|
# An in-memory fallback creates duplicate sequences when multiple pods or
|
||||||
|
# event producers handle the same transaction. Keep it opt-in only for
|
||||||
|
# local/single-process development.
|
||||||
|
return _env_bool("PUBSUB_SEQUENCE_MEMORY_FALLBACK", False)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_prefix() -> str:
|
||||||
|
return os.getenv("PUBSUB_SEQUENCE_KEY_PREFIX") or "observer:sequence"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_part(value: Any, fallback: str) -> str:
|
||||||
|
text = str(value or fallback).strip()
|
||||||
|
return text.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def build_sequence_key(
|
||||||
|
agent_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
transaction_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build one counter key for the whole transaction.
|
||||||
|
|
||||||
|
``agent_id`` is intentionally ignored for transaction-scoped counters.
|
||||||
|
A single transaction may emit events from different agents/components
|
||||||
|
(for example GRL and AGA), and those events must share one monotonic
|
||||||
|
sequence. ``session_id`` is retained only as a compatibility fallback when
|
||||||
|
no transaction identifier is present.
|
||||||
|
"""
|
||||||
|
if transaction_id:
|
||||||
|
transaction = _safe_part(transaction_id, "unknown_transaction")
|
||||||
|
return f"{_key_prefix()}:transaction:{transaction}"
|
||||||
|
|
||||||
|
# Legacy fallback. Including the agent here avoids changing old session-only
|
||||||
|
# behavior, but new integrations should always provide transactionId.
|
||||||
|
agent = _safe_part(agent_id or os.getenv("AGENT_NAME"), "agent")
|
||||||
|
session = _safe_part(session_id, "unknown_session")
|
||||||
|
return f"{_key_prefix()}:{agent}:session:{session}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_sequence_redis(key: str, ttl_seconds: int) -> int | None:
|
||||||
|
url = _redis_url()
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import redis.asyncio as redis_async # type: ignore
|
||||||
|
|
||||||
|
client = redis_async.Redis.from_url(url, decode_responses=True)
|
||||||
|
try:
|
||||||
|
value = await client.incr(key)
|
||||||
|
if ttl_seconds > 0 and value == 1:
|
||||||
|
await client.expire(key, ttl_seconds)
|
||||||
|
return int(value)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await client.aclose()
|
||||||
|
except AttributeError: # redis-py older compatibility
|
||||||
|
await client.close()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("tim_sequence.redis_failed key=%s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_mongo_index_checked = False
|
||||||
|
_mongo_index_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_sequence_mongodb_sync(
|
||||||
|
key: str,
|
||||||
|
agent_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
transaction_id: str | None,
|
||||||
|
ttl_seconds: int,
|
||||||
|
) -> int | None:
|
||||||
|
uri = _mongo_uri()
|
||||||
|
if not uri:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from pymongo import MongoClient, ReturnDocument # type: ignore
|
||||||
|
|
||||||
|
client = MongoClient(uri)
|
||||||
|
try:
|
||||||
|
collection = client[_mongo_database()][_mongo_collection()]
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + timedelta(seconds=ttl_seconds) if ttl_seconds > 0 else None
|
||||||
|
|
||||||
|
# update: dict[str, Any] = {
|
||||||
|
# "$inc": {"sequence": 1},
|
||||||
|
# "$set": {
|
||||||
|
# "agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
|
||||||
|
# "sessionId": session_id,
|
||||||
|
# "transactionId": transaction_id,
|
||||||
|
# "sequenceScope": "transaction" if transaction_id else "session",
|
||||||
|
# "updatedAt": now,
|
||||||
|
# },
|
||||||
|
# "$setOnInsert": {
|
||||||
|
# "_id": key,
|
||||||
|
# "createdAt": now,
|
||||||
|
# },
|
||||||
|
# }
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"$inc": {"sequence": 1},
|
||||||
|
"$set": {
|
||||||
|
"agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
|
||||||
|
"sessionId": session_id,
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"sequenceScope": "transaction" if transaction_id else "session",
|
||||||
|
"updatedAt": now,
|
||||||
|
},
|
||||||
|
"$setOnInsert": {
|
||||||
|
"createdAt": now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if expires_at is not None:
|
||||||
|
update["$set"]["expiresAt"] = expires_at
|
||||||
|
|
||||||
|
doc = collection.find_one_and_update(
|
||||||
|
{"_id": key},
|
||||||
|
update,
|
||||||
|
upsert=True,
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
if not doc:
|
||||||
|
return None
|
||||||
|
return int(doc.get("sequence", 0))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mongo_ttl_index_once_sync(ttl_seconds: int) -> None:
|
||||||
|
"""Best-effort TTL index initialization, safe across threads/event loops.
|
||||||
|
|
||||||
|
``asyncio.Lock`` must not be shared by independent event loops. Observer
|
||||||
|
compatibility calls may originate in worker threads, so this one-time
|
||||||
|
process-local guard deliberately uses ``threading.Lock``. The blocking
|
||||||
|
Mongo operation is executed by the async wrapper in a worker thread.
|
||||||
|
"""
|
||||||
|
global _mongo_index_checked
|
||||||
|
if _mongo_index_checked or ttl_seconds <= 0 or not _mongo_uri():
|
||||||
|
return
|
||||||
|
|
||||||
|
with _mongo_index_lock:
|
||||||
|
if _mongo_index_checked:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from pymongo import MongoClient # type: ignore
|
||||||
|
|
||||||
|
client = MongoClient(_mongo_uri())
|
||||||
|
try:
|
||||||
|
collection = client[_mongo_database()][_mongo_collection()]
|
||||||
|
collection.create_index("expiresAt", expireAfterSeconds=0, background=True)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("tim_sequence.mongodb_ttl_index_failed", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# The index is an observability housekeeping concern, not a
|
||||||
|
# prerequisite for sequence generation. Do not retry on every
|
||||||
|
# event if the application user lacks index privileges.
|
||||||
|
_mongo_index_checked = True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_mongo_ttl_index_once(ttl_seconds: int) -> None:
|
||||||
|
await asyncio.to_thread(_ensure_mongo_ttl_index_once_sync, ttl_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_sequence_mongodb(
|
||||||
|
key: str,
|
||||||
|
agent_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
transaction_id: str | None,
|
||||||
|
ttl_seconds: int,
|
||||||
|
) -> int | None:
|
||||||
|
if not _mongo_uri():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
await _ensure_mongo_ttl_index_once(ttl_seconds)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_next_sequence_mongodb_sync,
|
||||||
|
key,
|
||||||
|
agent_id,
|
||||||
|
session_id,
|
||||||
|
transaction_id,
|
||||||
|
ttl_seconds,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("tim_sequence.mongodb_failed key=%s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_sequence_memory(key: str) -> int:
|
||||||
|
# Tiny in-process critical section; a thread lock is intentional because
|
||||||
|
# this fallback can be reached from more than one asyncio event loop.
|
||||||
|
with _memory_lock:
|
||||||
|
_memory_counters[key] += 1
|
||||||
|
return _memory_counters[key]
|
||||||
|
|
||||||
|
|
||||||
|
async def next_sequence(
|
||||||
|
agent_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
transaction_id: str | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return the next observer sequence isolated by transaction.
|
||||||
|
|
||||||
|
The preferred scope is only ``transaction_id``. Agent/event family must
|
||||||
|
never participate in the key because one transaction can emit events from
|
||||||
|
several components. ``session_id`` is used only as a backward-compatible
|
||||||
|
fallback. Redis and MongoDB increments remain atomic across replicas.
|
||||||
|
"""
|
||||||
|
if not sequence_enabled() or (not transaction_id and not session_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
provider = _sequence_provider()
|
||||||
|
if provider == "none":
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = build_sequence_key(agent_id, session_id, transaction_id)
|
||||||
|
ttl_seconds = _ttl_seconds()
|
||||||
|
value: int | None = None
|
||||||
|
|
||||||
|
if provider == "memory":
|
||||||
|
return await _next_sequence_memory(key)
|
||||||
|
|
||||||
|
if provider == "redis":
|
||||||
|
value = await _next_sequence_redis(key, ttl_seconds)
|
||||||
|
elif provider == "mongodb":
|
||||||
|
value = await _next_sequence_mongodb(
|
||||||
|
key, agent_id, session_id, transaction_id, ttl_seconds
|
||||||
|
)
|
||||||
|
else: # auto
|
||||||
|
if _redis_url():
|
||||||
|
value = await _next_sequence_redis(key, ttl_seconds)
|
||||||
|
if value is None and _mongo_uri():
|
||||||
|
value = await _next_sequence_mongodb(
|
||||||
|
key, agent_id, session_id, transaction_id, ttl_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
if _fallback_enabled():
|
||||||
|
return await _next_sequence_memory(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_sequence(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Inject sequence if missing, preserving explicit values from metadata/body.
|
||||||
|
|
||||||
|
Used by the flat Pub/Sub schema, where sessionId/agentId sit at the root.
|
||||||
|
For the nested analytics envelope (OCI Streaming) use
|
||||||
|
:func:`ensure_sequence_envelope`.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
if payload.get("sequence") is not None:
|
||||||
|
return payload
|
||||||
|
session_id = payload.get("sessionId") or payload.get("session_id")
|
||||||
|
transaction_id = (
|
||||||
|
payload.get("transactionId")
|
||||||
|
or payload.get("transaction_id")
|
||||||
|
or payload.get("transactionID")
|
||||||
|
)
|
||||||
|
agent_id = payload.get("agentId") or payload.get("agent_id") or os.getenv("AGENT_NAME")
|
||||||
|
seq = await next_sequence(agent_id, session_id, transaction_id)
|
||||||
|
if seq is not None:
|
||||||
|
payload["sequence"] = seq
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_sequence_envelope(event: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Inject sequence into a ``build_analytics_event`` envelope.
|
||||||
|
|
||||||
|
The envelope shape is ``{eventType, source, eventDate, payload, metadata}``.
|
||||||
|
Unlike the flat Pub/Sub payload, sessionId/agentId are not at the root: they
|
||||||
|
live inside ``payload`` and/or ``metadata``. We read them from the merged
|
||||||
|
``{**payload, **metadata}`` view, mirroring the flat mapper
|
||||||
|
(tim_payload_mapper.map_analytics_event_to_tim_flat_payload) and the legacy
|
||||||
|
observer (observer/api.py: metadata.sessionId -> sessionId).
|
||||||
|
|
||||||
|
The counter is written at the envelope root, as a sibling of ``eventType`` —
|
||||||
|
the faithful analog of the legacy flat payload where ``sequence`` sat next to
|
||||||
|
``eventType``/``traceId``. The outer transport contract ``{type, payload}`` is
|
||||||
|
left untouched; only this inner field is added.
|
||||||
|
"""
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return event
|
||||||
|
if event.get("sequence") is not None:
|
||||||
|
return event
|
||||||
|
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||||
|
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
|
||||||
|
data = {**body, **metadata}
|
||||||
|
session_id = data.get("sessionId") or data.get("session_id")
|
||||||
|
# Os adapters do BO emitem snake_case; o contrato TIM usa transactionId e
|
||||||
|
# payloads antigos trazem transactionID. Sem as tres grafias o contador cai
|
||||||
|
# em escopo de sessao e perde o isolamento por transacao.
|
||||||
|
transaction_id = (
|
||||||
|
data.get("transactionId")
|
||||||
|
or data.get("transaction_id")
|
||||||
|
or data.get("transactionID")
|
||||||
|
)
|
||||||
|
agent_id = data.get("agentId") or data.get("agent_id") or os.getenv("AGENT_NAME")
|
||||||
|
seq = await next_sequence(agent_id, session_id, transaction_id)
|
||||||
|
if seq is not None:
|
||||||
|
event["sequence"] = seq
|
||||||
|
return event
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from .usage_repository import UsageRecord, UsageRepository, SQLiteUsageRepository, OracleUsageRepository, create_usage_repository
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework.observability.context import get_observability_context
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UsageRecord:
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
operation: str
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
cached_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
cost_usd: float = 0.0
|
||||||
|
cost_brl: float = 0.0
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
agent_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_usage(cls, provider: str, model: str, operation: str, usage: dict[str, Any], metadata: dict[str, Any] | None = None) -> "UsageRecord":
|
||||||
|
ctx = get_observability_context()
|
||||||
|
return cls(
|
||||||
|
provider=provider, model=model, operation=operation,
|
||||||
|
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
||||||
|
completion_tokens=int(usage.get("completion_tokens") or 0),
|
||||||
|
cached_tokens=int(usage.get("cached_tokens") or 0),
|
||||||
|
total_tokens=int(usage.get("total_tokens") or 0),
|
||||||
|
cost_usd=float(usage.get("cost_usd") or 0),
|
||||||
|
cost_brl=float(usage.get("cost_brl") or 0),
|
||||||
|
metadata=metadata or {}, request_id=ctx.request_id, session_id=ctx.session_id,
|
||||||
|
tenant_id=ctx.tenant_id, agent_id=ctx.agent_id, user_id=ctx.user_id,
|
||||||
|
message_id=ctx.message_id, created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def model_dump(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
class UsageRepository:
|
||||||
|
async def record(self, usage: UsageRecord) -> None: ...
|
||||||
|
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
class SQLiteUsageRepository(UsageRepository):
|
||||||
|
def __init__(self, settings):
|
||||||
|
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||||
|
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
ddl = """
|
||||||
|
create table if not exists llm_usage_records (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
request_id text, session_id text, tenant_id text, agent_id text, user_id text, message_id text,
|
||||||
|
provider text not null, model text not null, operation text not null,
|
||||||
|
prompt_tokens integer not null default 0,
|
||||||
|
completion_tokens integer not null default 0,
|
||||||
|
cached_tokens integer not null default 0,
|
||||||
|
total_tokens integer not null default 0,
|
||||||
|
cost_usd real not null default 0,
|
||||||
|
cost_brl real not null default 0,
|
||||||
|
metadata_json text,
|
||||||
|
created_at text not null
|
||||||
|
);
|
||||||
|
create index if not exists idx_usage_tenant_created on llm_usage_records(tenant_id, created_at);
|
||||||
|
create index if not exists idx_usage_session_created on llm_usage_records(session_id, created_at);
|
||||||
|
"""
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
con.executescript(ddl)
|
||||||
|
|
||||||
|
async def record(self, usage: UsageRecord) -> None:
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
con.execute("""
|
||||||
|
insert into llm_usage_records(
|
||||||
|
request_id,session_id,tenant_id,agent_id,user_id,message_id,
|
||||||
|
provider,model,operation,prompt_tokens,completion_tokens,cached_tokens,total_tokens,
|
||||||
|
cost_usd,cost_brl,metadata_json,created_at
|
||||||
|
) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
|
||||||
|
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens,
|
||||||
|
usage.cached_tokens, usage.total_tokens, usage.cost_usd, usage.cost_brl,
|
||||||
|
json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
|
||||||
|
where=[]; params=[]
|
||||||
|
if tenant_id: where.append('tenant_id=?'); params.append(tenant_id)
|
||||||
|
if session_id: where.append('session_id=?'); params.append(session_id)
|
||||||
|
sql="""select count(*) calls, coalesce(sum(prompt_tokens),0) prompt_tokens,
|
||||||
|
coalesce(sum(completion_tokens),0) completion_tokens,
|
||||||
|
coalesce(sum(total_tokens),0) total_tokens,
|
||||||
|
coalesce(sum(cost_usd),0) cost_usd,
|
||||||
|
coalesce(sum(cost_brl),0) cost_brl
|
||||||
|
from llm_usage_records"""
|
||||||
|
if where: sql += ' where ' + ' and '.join(where)
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
row=con.execute(sql, params).fetchone()
|
||||||
|
return dict(row) if row else {"calls":0,"prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"cost_usd":0,"cost_brl":0}
|
||||||
|
|
||||||
|
class OracleUsageRepository(UsageRepository):
|
||||||
|
def __init__(self, settings):
|
||||||
|
from agent_framework.persistence.oracle_store import OracleStore
|
||||||
|
self.store = OracleStore(settings)
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur=conn.cursor()
|
||||||
|
self.store._exec_ddl_ignore_exists(cur, f"""
|
||||||
|
create table {self.store.t('LLM_USAGE_RECORD')} (
|
||||||
|
ID number generated always as identity primary key,
|
||||||
|
REQUEST_ID varchar2(128), SESSION_ID varchar2(256), TENANT_ID varchar2(128),
|
||||||
|
AGENT_ID varchar2(128), USER_ID varchar2(256), MESSAGE_ID varchar2(256),
|
||||||
|
PROVIDER varchar2(128) not null, MODEL varchar2(256) not null, OPERATION varchar2(128) not null,
|
||||||
|
PROMPT_TOKENS number default 0, COMPLETION_TOKENS number default 0, CACHED_TOKENS number default 0,
|
||||||
|
TOTAL_TOKENS number default 0, COST_USD number default 0, COST_BRL number default 0,
|
||||||
|
METADATA_JSON clob check (METADATA_JSON is json), CREATED_AT timestamp with time zone not null
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_TENANT')} on {self.store.t('LLM_USAGE_RECORD')}(TENANT_ID, CREATED_AT)")
|
||||||
|
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_SESSION')} on {self.store.t('LLM_USAGE_RECORD')}(SESSION_ID, CREATED_AT)")
|
||||||
|
|
||||||
|
async def record(self, usage: UsageRecord) -> None:
|
||||||
|
await asyncio.to_thread(self._record_sync, usage)
|
||||||
|
|
||||||
|
def _record_sync(self, usage: UsageRecord):
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
conn.cursor().execute(f"""
|
||||||
|
insert into {self.store.t('LLM_USAGE_RECORD')}(
|
||||||
|
REQUEST_ID,SESSION_ID,TENANT_ID,AGENT_ID,USER_ID,MESSAGE_ID,PROVIDER,MODEL,OPERATION,
|
||||||
|
PROMPT_TOKENS,COMPLETION_TOKENS,CACHED_TOKENS,TOTAL_TOKENS,COST_USD,COST_BRL,METADATA_JSON,CREATED_AT
|
||||||
|
) values(:1,:2,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,:15,:16,:17)
|
||||||
|
""", [
|
||||||
|
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
|
||||||
|
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens, usage.cached_tokens,
|
||||||
|
usage.total_tokens, usage.cost_usd, usage.cost_brl, json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
|
||||||
|
])
|
||||||
|
|
||||||
|
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
|
||||||
|
return await asyncio.to_thread(self._summarize_sync, tenant_id, session_id)
|
||||||
|
|
||||||
|
def _summarize_sync(self, tenant_id, session_id):
|
||||||
|
where=[]; params={}
|
||||||
|
if tenant_id: where.append('TENANT_ID=:tenant_id'); params['tenant_id']=tenant_id
|
||||||
|
if session_id: where.append('SESSION_ID=:session_id'); params['session_id']=session_id
|
||||||
|
sql=f"""select count(*) CALLS, coalesce(sum(PROMPT_TOKENS),0) PROMPT_TOKENS,
|
||||||
|
coalesce(sum(COMPLETION_TOKENS),0) COMPLETION_TOKENS,
|
||||||
|
coalesce(sum(TOTAL_TOKENS),0) TOTAL_TOKENS,
|
||||||
|
coalesce(sum(COST_USD),0) COST_USD,
|
||||||
|
coalesce(sum(COST_BRL),0) COST_BRL
|
||||||
|
from {self.store.t('LLM_USAGE_RECORD')}"""
|
||||||
|
if where: sql += ' where ' + ' and '.join(where)
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur=conn.cursor(); cur.execute(sql, params); row=cur.fetchone()
|
||||||
|
cols=[d[0].lower() for d in cur.description]
|
||||||
|
return dict(zip(cols,row)) if row else {}
|
||||||
|
|
||||||
|
def create_usage_repository(settings) -> UsageRepository:
|
||||||
|
provider = getattr(settings, 'USAGE_REPOSITORY_PROVIDER', None) or getattr(settings, 'MEMORY_REPOSITORY_PROVIDER', 'memory')
|
||||||
|
if provider in {'autonomous','oracle'}:
|
||||||
|
return OracleUsageRepository(settings)
|
||||||
|
return SQLiteUsageRepository(settings)
|
||||||
0
agent_framework_oci/libs/agent_framework/build/lib/agent_framework/cache/__init__.py
vendored
Normal file
0
agent_framework_oci/libs/agent_framework/build/lib/agent_framework/cache/__init__.py
vendored
Normal file
184
agent_framework_oci/libs/agent_framework/build/lib/agent_framework/cache/cache.py
vendored
Normal file
184
agent_framework_oci/libs/agent_framework/build/lib/agent_framework/cache/cache.py
vendored
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.cache")
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
async def get(self, key: str) -> Any | None: ...
|
||||||
|
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None: ...
|
||||||
|
async def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCache(Cache):
|
||||||
|
def __init__(self):
|
||||||
|
self._data: dict[str, tuple[Any, float | None]] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
async with self._lock:
|
||||||
|
item = self._data.get(key)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
value, expires = item
|
||||||
|
if expires and expires < time.time():
|
||||||
|
self._data.pop(key, None)
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def set(self, key, value, ttl_seconds=None):
|
||||||
|
async with self._lock:
|
||||||
|
self._data[key] = (value, time.time() + ttl_seconds if ttl_seconds else None)
|
||||||
|
|
||||||
|
async def delete(self, key):
|
||||||
|
async with self._lock:
|
||||||
|
self._data.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache(Cache):
|
||||||
|
"""Redis L2 cache with redis-py sync/async compatibility and safe fallback."""
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.url = settings.REDIS_URL
|
||||||
|
self.prefix = getattr(settings, "CACHE_KEY_PREFIX", "agentfw")
|
||||||
|
self._async = False
|
||||||
|
try:
|
||||||
|
import redis.asyncio as redis_async
|
||||||
|
self.client = redis_async.Redis.from_url(self.url, decode_responses=True)
|
||||||
|
self._async = True
|
||||||
|
except Exception:
|
||||||
|
import redis
|
||||||
|
self.client = redis.Redis.from_url(self.url, decode_responses=True)
|
||||||
|
|
||||||
|
def _key(self, key: str) -> str:
|
||||||
|
return f"{self.prefix}:{key}"
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
try:
|
||||||
|
raw = await self.client.get(self._key(key)) if self._async else await asyncio.to_thread(self.client.get, self._key(key))
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Redis GET falhou key=%s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(self, key, value, ttl_seconds=None):
|
||||||
|
raw = json.dumps(value, ensure_ascii=False, default=str)
|
||||||
|
try:
|
||||||
|
if self._async:
|
||||||
|
await self.client.set(self._key(key), raw, ex=ttl_seconds)
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(self.client.set, self._key(key), raw, ex=ttl_seconds)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Redis SET falhou key=%s", key)
|
||||||
|
|
||||||
|
async def delete(self, key):
|
||||||
|
try:
|
||||||
|
if self._async:
|
||||||
|
await self.client.delete(self._key(key))
|
||||||
|
else:
|
||||||
|
await asyncio.to_thread(self.client.delete, self._key(key))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Redis DELETE falhou key=%s", key)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteCache(Cache):
|
||||||
|
def __init__(self, settings):
|
||||||
|
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||||
|
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return await asyncio.to_thread(self._get_sync, key)
|
||||||
|
|
||||||
|
def _get_sync(self, key):
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
row = con.execute("select value_json, expires_at from cache_entries where key=?", (key,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row["expires_at"] and row["expires_at"] < time.time():
|
||||||
|
con.execute("delete from cache_entries where key=?", (key,))
|
||||||
|
return None
|
||||||
|
return json.loads(row["value_json"])
|
||||||
|
|
||||||
|
async def set(self, key, value, ttl_seconds=None):
|
||||||
|
await asyncio.to_thread(self._set_sync, key, value, ttl_seconds)
|
||||||
|
|
||||||
|
def _set_sync(self, key, value, ttl_seconds=None):
|
||||||
|
expires = time.time() + ttl_seconds if ttl_seconds else None
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
con.execute(
|
||||||
|
"insert or replace into cache_entries(key,value_json,expires_at,created_at) values(?,?,?,?)",
|
||||||
|
(key, json.dumps(value, ensure_ascii=False, default=str), expires, self.store.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, key):
|
||||||
|
await asyncio.to_thread(self._delete_sync, key)
|
||||||
|
|
||||||
|
def _delete_sync(self, key):
|
||||||
|
with self.store._lock, self.store.connect() as con:
|
||||||
|
con.execute("delete from cache_entries where key=?", (key,))
|
||||||
|
|
||||||
|
|
||||||
|
class OracleCache(Cache):
|
||||||
|
def __init__(self, settings):
|
||||||
|
from agent_framework.persistence.oracle_store import OracleStore
|
||||||
|
self.store = OracleStore(settings)
|
||||||
|
|
||||||
|
async def get(self, key): return await self.store.cache_get(key)
|
||||||
|
async def set(self, key, value, ttl_seconds=None):
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) if ttl_seconds else None
|
||||||
|
await self.store.cache_set(key, value, expires_at=expires)
|
||||||
|
async def delete(self, key): await self.store.cache_delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class DistributedCache(Cache):
|
||||||
|
"""L1 memory + optional L2 Redis/SQLite/Oracle with telemetry hooks."""
|
||||||
|
def __init__(self, l1: Cache, l2: Cache | None = None, telemetry=None, default_ttl: int | None = None):
|
||||||
|
self.l1, self.l2, self.telemetry, self.default_ttl = l1, l2, telemetry, default_ttl
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
v = await self.l1.get(key)
|
||||||
|
if v is not None:
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("hit.l1", key, True)
|
||||||
|
return v
|
||||||
|
if not self.l2:
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
|
||||||
|
return None
|
||||||
|
v = await self.l2.get(key)
|
||||||
|
if v is not None:
|
||||||
|
await self.l1.set(key, v, self.default_ttl)
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("hit.l2", key, True)
|
||||||
|
return v
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(self, key, value, ttl_seconds=None):
|
||||||
|
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl
|
||||||
|
await self.l1.set(key, value, ttl)
|
||||||
|
if self.l2: await self.l2.set(key, value, ttl)
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("set", key, None, {"ttl_seconds": ttl})
|
||||||
|
|
||||||
|
async def delete(self, key):
|
||||||
|
await self.l1.delete(key)
|
||||||
|
if self.l2: await self.l2.delete(key)
|
||||||
|
if self.telemetry: await self.telemetry.cache_event("delete", key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cache(settings, telemetry=None):
|
||||||
|
l1 = InMemoryCache()
|
||||||
|
l2 = None
|
||||||
|
if getattr(settings, "ENABLE_REDIS_CACHE", False):
|
||||||
|
try:
|
||||||
|
l2 = RedisCache(settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Redis indisponível; cache seguirá apenas com L1 memória")
|
||||||
|
l2 = None
|
||||||
|
if l2 is None:
|
||||||
|
provider = getattr(settings, "CACHE_BACKEND_PROVIDER", "memory")
|
||||||
|
if provider == "sqlite": l2 = SQLiteCache(settings)
|
||||||
|
elif provider in {"autonomous", "oracle"}: l2 = OracleCache(settings)
|
||||||
|
return DistributedCache(l1, l2, telemetry=telemetry, default_ttl=getattr(settings, "CACHE_TTL_SECONDS", None))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from .base import ChannelAdapter, ChannelMessage, ChannelResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_context(payload: dict) -> dict:
|
||||||
|
"""Preserva todo payload como contexto.
|
||||||
|
|
||||||
|
Antes o WebAdapter só copiava payload["context"]. Com isso, campos como
|
||||||
|
business_context, msisdn, invoice_id e ura_call_id eram perdidos antes de
|
||||||
|
chegar ao workflow/MCP.
|
||||||
|
"""
|
||||||
|
payload = dict(payload or {})
|
||||||
|
ctx = dict(payload.get("context") or {})
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k != "context" and k not in ctx:
|
||||||
|
ctx[k] = v
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class WebAdapter(ChannelAdapter):
|
||||||
|
name = "web"
|
||||||
|
|
||||||
|
async def normalize(self, payload):
|
||||||
|
payload = payload or {}
|
||||||
|
text = payload.get("message") or payload.get("text") or payload.get("content") or ""
|
||||||
|
return ChannelMessage(
|
||||||
|
channel="web",
|
||||||
|
text=text,
|
||||||
|
session_id=payload.get("session_id"),
|
||||||
|
user_id=payload.get("user_id"),
|
||||||
|
channel_id=payload.get("channel_id") or payload.get("channelId"),
|
||||||
|
context=_merge_context(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def render(self, response):
|
||||||
|
return response.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppAdapter(ChannelAdapter):
|
||||||
|
name = "whatsapp"
|
||||||
|
|
||||||
|
async def normalize(self, payload):
|
||||||
|
payload = payload or {}
|
||||||
|
return ChannelMessage(
|
||||||
|
channel="whatsapp",
|
||||||
|
channel_id=payload.get("from"),
|
||||||
|
text=payload.get("text") or payload.get("message") or "",
|
||||||
|
session_id=payload.get("session_id"),
|
||||||
|
context=_merge_context(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def render(self, response):
|
||||||
|
return {"to": response.metadata.get("channel_id"), "text": response.text, "session_id": response.session_id}
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceAdapter(ChannelAdapter):
|
||||||
|
name = "voice"
|
||||||
|
|
||||||
|
async def normalize(self, payload):
|
||||||
|
payload = payload or {}
|
||||||
|
return ChannelMessage(
|
||||||
|
channel="voice",
|
||||||
|
channel_id=payload.get("ani"),
|
||||||
|
text=payload.get("transcript") or payload.get("text") or payload.get("message") or "",
|
||||||
|
session_id=payload.get("session_id"),
|
||||||
|
context=_merge_context(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def render(self, response):
|
||||||
|
return {"speak": response.text, "session_id": response.session_id}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class ChannelMessage(BaseModel):
|
||||||
|
channel: str
|
||||||
|
channel_id: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
text: str
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class ChannelResponse(BaseModel):
|
||||||
|
channel: str
|
||||||
|
session_id: str
|
||||||
|
text: str
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
class ChannelAdapter:
|
||||||
|
name = 'base'
|
||||||
|
async def normalize(self, payload: dict) -> ChannelMessage: ...
|
||||||
|
async def render(self, response: ChannelResponse) -> dict: ...
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .adapters import WebAdapter, WhatsAppAdapter, VoiceAdapter, _merge_context
|
||||||
|
from .base import ChannelMessage, ChannelResponse
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agent_framework.config.settings import settings
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
settings = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelGateway:
|
||||||
|
"""Normalize and render messages at the Agent Framework boundary.
|
||||||
|
|
||||||
|
This class is used by the Agent Framework backend, not by the external
|
||||||
|
Channel Gateway service.
|
||||||
|
|
||||||
|
input_mode semantics:
|
||||||
|
- embedded: the backend may use internal channel adapters to interpret
|
||||||
|
simple/native channel payloads. This is useful for demos, labs and local
|
||||||
|
testing.
|
||||||
|
- external: the backend expects a GatewayRequest payload that was already
|
||||||
|
normalized by an external Channel Gateway. In this mode the backend does
|
||||||
|
not parse native WhatsApp, Voice, Teams, or other channel payloads.
|
||||||
|
|
||||||
|
Backward compatibility:
|
||||||
|
- The legacy constructor argument ``mode`` and setting
|
||||||
|
``CHANNEL_GATEWAY_MODE`` are still accepted, but the preferred setting is
|
||||||
|
``FRAMEWORK_CHANNEL_INPUT_MODE``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, input_mode: str | None = None, mode: str | None = None):
|
||||||
|
configured = (
|
||||||
|
input_mode
|
||||||
|
or mode
|
||||||
|
or getattr(settings, "FRAMEWORK_CHANNEL_INPUT_MODE", None)
|
||||||
|
or getattr(settings, "CHANNEL_GATEWAY_MODE", None)
|
||||||
|
or "embedded"
|
||||||
|
)
|
||||||
|
self.input_mode = str(configured).strip().lower()
|
||||||
|
if self.input_mode not in {"embedded", "external"}:
|
||||||
|
raise ValueError(
|
||||||
|
"INVALID_FRAMEWORK_CHANNEL_INPUT_MODE: expected 'embedded' or 'external'"
|
||||||
|
)
|
||||||
|
# Compatibility with previous code that accessed gateway.mode.
|
||||||
|
self.mode = self.input_mode
|
||||||
|
self.adapters = {a.name: a for a in [WebAdapter(), WhatsAppAdapter(), VoiceAdapter()]}
|
||||||
|
|
||||||
|
def get(self, channel: str):
|
||||||
|
return self.adapters.get(channel, self.adapters["web"])
|
||||||
|
|
||||||
|
def _validate_external_payload(self, channel: str, payload: dict):
|
||||||
|
"""Validate the payload portion of a GatewayRequest.
|
||||||
|
|
||||||
|
In external input mode, the backend is not accepting native channel
|
||||||
|
payloads. It expects req.channel plus req.payload.message at minimum.
|
||||||
|
Business keys remain optional because some journeys start without all
|
||||||
|
identifiers and are completed by IdentityResolver or the agent.
|
||||||
|
"""
|
||||||
|
if not isinstance(channel, str) or not channel.strip():
|
||||||
|
raise ValueError("INVALID_GATEWAY_REQUEST: channel is required")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("INVALID_GATEWAY_REQUEST: payload must be an object")
|
||||||
|
message = payload.get("message")
|
||||||
|
if not isinstance(message, str) or not message.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"INVALID_GATEWAY_REQUEST: payload.message is required and must be a non-empty string"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _normalize_external(self, channel: str, payload: dict) -> ChannelMessage:
|
||||||
|
self._validate_external_payload(channel, payload)
|
||||||
|
return ChannelMessage(
|
||||||
|
channel=channel,
|
||||||
|
text=payload.get("message"),
|
||||||
|
session_id=payload.get("session_id") or payload.get("session_key"),
|
||||||
|
user_id=payload.get("user_id"),
|
||||||
|
channel_id=payload.get("channel_id") or payload.get("channelId"),
|
||||||
|
context=_merge_context(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def normalize(self, channel: str, payload: dict) -> ChannelMessage:
|
||||||
|
if self.input_mode == "external":
|
||||||
|
return await self._normalize_external(channel, payload)
|
||||||
|
return await self.get(channel).normalize(payload)
|
||||||
|
|
||||||
|
async def render(self, response: ChannelResponse) -> dict:
|
||||||
|
if self.input_mode == "external":
|
||||||
|
# The external Channel Gateway owns the final translation back to
|
||||||
|
# WhatsApp, Voice, Teams, etc. The backend returns its canonical
|
||||||
|
# response shape.
|
||||||
|
return response.model_dump()
|
||||||
|
return await self.get(response.channel).render(response)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class InterruptionDecision:
|
||||||
|
action: str # process | replay | classify
|
||||||
|
text: str
|
||||||
|
replay_text: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
is_interruptible: bool = True
|
||||||
|
terminal_status: str = ""
|
||||||
|
heard_text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _idle_nudges(payload: dict[str, Any]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for event in payload.get("events") or []:
|
||||||
|
if not isinstance(event, dict) or event.get("type") != "idle_nudge":
|
||||||
|
continue
|
||||||
|
text = str(event.get("text") or "").strip()
|
||||||
|
if text and text not in seen:
|
||||||
|
seen.add(text)
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def classify_processing_interruption(
|
||||||
|
llm: Any,
|
||||||
|
*,
|
||||||
|
original_agent: str,
|
||||||
|
original_client: str = "",
|
||||||
|
supplement_client: str = "",
|
||||||
|
profile_name: str = "processing_interruption_classifier",
|
||||||
|
) -> bool:
|
||||||
|
"""Decide se um barge-in interrompível exige regeneração da resposta.
|
||||||
|
|
||||||
|
Fail-safe: qualquer erro, resposta vazia ou formato inesperado retorna False,
|
||||||
|
fazendo replay da fala anterior. O domínio não conhece este classificador;
|
||||||
|
ele usa exclusivamente o LLMProvider do framework.
|
||||||
|
"""
|
||||||
|
if llm is None:
|
||||||
|
return False
|
||||||
|
prompt = (
|
||||||
|
"Você classifica interrupções de voz durante uma resposta de atendimento. "
|
||||||
|
"Responda somente 1 ou 0.\n"
|
||||||
|
"1 = a fala/complemento do cliente adiciona ou altera informação relevante e "
|
||||||
|
"a resposta do agente deve ser regenerada.\n"
|
||||||
|
"0 = a interrupção não exige nova resposta; a fala anterior deve ser repetida.\n\n"
|
||||||
|
f"Última fala do agente: {original_agent}\n"
|
||||||
|
f"Última fala do cliente antes da resposta: {original_client}\n"
|
||||||
|
f"Complemento/interrupção atual: {supplement_client}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await llm.ainvoke(
|
||||||
|
[{"role": "system", "content": prompt}],
|
||||||
|
temperature=0,
|
||||||
|
max_tokens=8,
|
||||||
|
profile_name=profile_name,
|
||||||
|
component_name=profile_name,
|
||||||
|
generation_name=f"llm.{profile_name}",
|
||||||
|
)
|
||||||
|
raw = getattr(response, "content", response)
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
return text.startswith("1")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_interruption(
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
message_text: str,
|
||||||
|
session_metadata: dict[str, Any] | None,
|
||||||
|
terminal_fallback_text: str = "",
|
||||||
|
terminal_fallback_status: str = "erro_falha_sistema",
|
||||||
|
) -> InterruptionDecision:
|
||||||
|
"""Framework-level replay/interruption policy.
|
||||||
|
|
||||||
|
- sessão terminal: replay da última fala/fallback, sem reabrir o workflow;
|
||||||
|
- idle_nudge: replay da última fala real;
|
||||||
|
- fala não interrompível: replay;
|
||||||
|
- fala interrompível com fala anterior: classificar antes de regenerar;
|
||||||
|
- sem contexto anterior suficiente: processar normalmente.
|
||||||
|
"""
|
||||||
|
metadata = session_metadata or {}
|
||||||
|
last_text = str(metadata.get("last_assistant_text") or "").strip()
|
||||||
|
last_interruptible = bool(metadata.get("last_assistant_is_interruptible", True))
|
||||||
|
|
||||||
|
if bool(metadata.get("conversation_closed")):
|
||||||
|
replay_text = (
|
||||||
|
last_text
|
||||||
|
or str(metadata.get("terminal_replay_text") or "").strip()
|
||||||
|
or str(terminal_fallback_text or "").strip()
|
||||||
|
)
|
||||||
|
terminal_status = str(metadata.get("terminal_status") or "").strip() or terminal_fallback_status
|
||||||
|
if replay_text:
|
||||||
|
return InterruptionDecision(
|
||||||
|
action="replay",
|
||||||
|
text=message_text,
|
||||||
|
replay_text=replay_text,
|
||||||
|
reason="post_finalize",
|
||||||
|
is_interruptible=False,
|
||||||
|
terminal_status=terminal_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _idle_nudges(payload) and last_text:
|
||||||
|
return InterruptionDecision(
|
||||||
|
action="replay",
|
||||||
|
text=message_text,
|
||||||
|
replay_text=last_text,
|
||||||
|
reason="idle_nudge",
|
||||||
|
is_interruptible=last_interruptible,
|
||||||
|
)
|
||||||
|
|
||||||
|
interruption = payload.get("processing_interruption")
|
||||||
|
if isinstance(interruption, dict):
|
||||||
|
heard = str(interruption.get("heard_text") or "").strip()
|
||||||
|
current_text = str(message_text or heard).strip()
|
||||||
|
if not last_interruptible and last_text:
|
||||||
|
return InterruptionDecision(
|
||||||
|
action="replay",
|
||||||
|
text=current_text,
|
||||||
|
replay_text=last_text,
|
||||||
|
reason="non_interruptible_speech",
|
||||||
|
is_interruptible=False,
|
||||||
|
heard_text=heard,
|
||||||
|
)
|
||||||
|
if last_text:
|
||||||
|
return InterruptionDecision(
|
||||||
|
action="classify",
|
||||||
|
text=current_text,
|
||||||
|
replay_text=last_text,
|
||||||
|
reason="interruptible_speech",
|
||||||
|
is_interruptible=True,
|
||||||
|
heard_text=heard,
|
||||||
|
)
|
||||||
|
return InterruptionDecision(
|
||||||
|
action="process",
|
||||||
|
text=current_text,
|
||||||
|
reason="interruptible_speech_no_history",
|
||||||
|
is_interruptible=True,
|
||||||
|
heard_text=heard,
|
||||||
|
)
|
||||||
|
|
||||||
|
return InterruptionDecision(action="process", text=message_text)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InterruptionDecision",
|
||||||
|
"classify_processing_interruption",
|
||||||
|
"evaluate_interruption",
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Correções determinísticas e conservadoras para transcrição de canal de voz."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
# Só falas inteiras entram nesta tabela. Nunca substitua tokens dentro de frases.
|
||||||
|
DEFAULT_WHOLE_UTTERANCE_FIXES: dict[str, str] = {
|
||||||
|
"fim": "Sim",
|
||||||
|
"mim": "Sim",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TRAILING_PUNCT = re.compile(r"[.!?]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def fix_whole_utterance_transcription(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
fixes: Mapping[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
raw = str(text or "")
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return raw
|
||||||
|
candidate = _TRAILING_PUNCT.sub("", stripped).strip().casefold()
|
||||||
|
table = fixes or DEFAULT_WHOLE_UTTERANCE_FIXES
|
||||||
|
replacement = table.get(candidate)
|
||||||
|
return str(replacement) if replacement is not None else raw
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["DEFAULT_WHOLE_UTTERANCE_FIXES", "fix_whole_utterance_transcription"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from .checkpoint_repository import (
|
||||||
|
AutonomousCheckpointRepository,
|
||||||
|
CheckpointIntegrityError,
|
||||||
|
CheckpointIntegrityService,
|
||||||
|
CheckpointRecoveryError,
|
||||||
|
InMemoryCheckpointRepository,
|
||||||
|
LangGraphCheckpointRepository,
|
||||||
|
OracleCheckpointRepository,
|
||||||
|
ResilientCheckpointRepository,
|
||||||
|
RetryPolicy,
|
||||||
|
SQLiteCheckpointRepository,
|
||||||
|
create_checkpoint_repository,
|
||||||
|
create_raw_checkpoint_repository,
|
||||||
|
)
|
||||||
|
from .langgraph_saver import RepositoryCheckpointSaver, create_langgraph_checkpointer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AutonomousCheckpointRepository",
|
||||||
|
"CheckpointIntegrityError",
|
||||||
|
"CheckpointIntegrityService",
|
||||||
|
"CheckpointRecoveryError",
|
||||||
|
"InMemoryCheckpointRepository",
|
||||||
|
"LangGraphCheckpointRepository",
|
||||||
|
"OracleCheckpointRepository",
|
||||||
|
"RepositoryCheckpointSaver",
|
||||||
|
"ResilientCheckpointRepository",
|
||||||
|
"RetryPolicy",
|
||||||
|
"SQLiteCheckpointRepository",
|
||||||
|
"create_checkpoint_repository",
|
||||||
|
"create_langgraph_checkpointer",
|
||||||
|
"create_raw_checkpoint_repository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from agent_framework.persistence.sqlite_store import SQLiteStore
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent_framework.checkpoints")
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(value: str | bytes | None, default: Any):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_json_dumps(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointIntegrityError(RuntimeError):
|
||||||
|
"""Raised when a persisted checkpoint envelope fails checksum validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointRecoveryError(RuntimeError):
|
||||||
|
"""Raised when recovery cannot find a valid checkpoint."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetryPolicy:
|
||||||
|
max_attempts: int = 3
|
||||||
|
base_delay_seconds: float = 0.05
|
||||||
|
max_delay_seconds: float = 1.0
|
||||||
|
jitter_seconds: float = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointIntegrityService:
|
||||||
|
"""Creates and validates immutable checkpoint envelopes.
|
||||||
|
|
||||||
|
The repository stores an envelope instead of only the raw LangGraph payload:
|
||||||
|
- schema_version: enables future migrations;
|
||||||
|
- payload_hash: SHA-256 over the payload;
|
||||||
|
- envelope_id: idempotency/correlation id;
|
||||||
|
- compacted: marks synthetic compacted snapshots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
ENVELOPE_MARKER = "agent_framework_checkpoint_envelope"
|
||||||
|
|
||||||
|
def wrap(self, thread_id: str, checkpoint: dict[str, Any], *, compacted: bool = False) -> dict[str, Any]:
|
||||||
|
payload = checkpoint or {}
|
||||||
|
return {
|
||||||
|
"_type": self.ENVELOPE_MARKER,
|
||||||
|
"schema_version": self.SCHEMA_VERSION,
|
||||||
|
"envelope_id": str(uuid.uuid4()),
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"checkpoint_id": str(payload.get("checkpoint_id") or (payload.get("checkpoint") or {}).get("id") or uuid.uuid4()),
|
||||||
|
"payload_hash": _sha256(payload),
|
||||||
|
"payload": payload,
|
||||||
|
"compacted": bool(compacted),
|
||||||
|
"created_at": _utc_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_envelope(self, value: dict[str, Any] | None) -> bool:
|
||||||
|
return isinstance(value, dict) and value.get("_type") == self.ENVELOPE_MARKER
|
||||||
|
|
||||||
|
def unwrap(self, value: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not self.is_envelope(value):
|
||||||
|
# Backwards compatibility with old checkpoints from previous project versions.
|
||||||
|
return value
|
||||||
|
expected = value.get("payload_hash")
|
||||||
|
payload = value.get("payload") or {}
|
||||||
|
actual = _sha256(payload)
|
||||||
|
if expected != actual:
|
||||||
|
raise CheckpointIntegrityError(
|
||||||
|
f"Checkpoint corrompido para thread_id={value.get('thread_id')}: hash esperado={expected}, hash atual={actual}"
|
||||||
|
)
|
||||||
|
if int(value.get("schema_version") or 0) > self.SCHEMA_VERSION:
|
||||||
|
raise CheckpointIntegrityError(
|
||||||
|
f"Checkpoint usa schema_version={value.get('schema_version')} maior que o suportado={self.SCHEMA_VERSION}"
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class LangGraphCheckpointRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_latest(self, thread_id: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
|
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
latest = await self.get_latest(thread_id)
|
||||||
|
return [latest] if latest else []
|
||||||
|
|
||||||
|
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid_checkpoint(checkpoint):
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
return False
|
||||||
|
if "v" in checkpoint:
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
"checkpoint" in checkpoint
|
||||||
|
and isinstance(checkpoint["checkpoint"], dict)
|
||||||
|
and "v" in checkpoint["checkpoint"]
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
class InMemoryCheckpointRepository(LangGraphCheckpointRepository):
|
||||||
|
def __init__(self):
|
||||||
|
self._data: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||||
|
self._data.setdefault(thread_id, []).append(checkpoint)
|
||||||
|
|
||||||
|
async def get_latest(self, thread_id: str):
|
||||||
|
items = self._data.get(thread_id, [])
|
||||||
|
return items[-1] if items else None
|
||||||
|
|
||||||
|
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
return list(reversed(self._data.get(thread_id, [])[-limit:]))
|
||||||
|
|
||||||
|
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||||
|
items = self._data.get(thread_id, [])
|
||||||
|
if len(items) <= keep_last:
|
||||||
|
return 0
|
||||||
|
removed = len(items) - keep_last
|
||||||
|
self._data[thread_id] = items[-keep_last:]
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteCheckpointRepository(LangGraphCheckpointRepository):
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
|
||||||
|
|
||||||
|
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||||
|
await asyncio.to_thread(self.store.put_checkpoint, thread_id, checkpoint)
|
||||||
|
|
||||||
|
async def get_latest(self, thread_id: str):
|
||||||
|
return await asyncio.to_thread(self.store.get_latest_checkpoint, thread_id)
|
||||||
|
|
||||||
|
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
def _list():
|
||||||
|
with self.store.connect() as con:
|
||||||
|
rows = con.execute(
|
||||||
|
"select checkpoint_json from workflow_checkpoints where thread_id=? order by id desc limit ?",
|
||||||
|
(thread_id, int(limit)),
|
||||||
|
).fetchall()
|
||||||
|
return [_json_loads(r["checkpoint_json"], None) for r in rows if r]
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_list)
|
||||||
|
|
||||||
|
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||||
|
def _compact():
|
||||||
|
with self.store.connect() as con:
|
||||||
|
rows = con.execute(
|
||||||
|
"select id from workflow_checkpoints where thread_id=? order by id desc",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchall()
|
||||||
|
ids = [int(r["id"]) for r in rows]
|
||||||
|
delete_ids = ids[int(keep_last):]
|
||||||
|
if not delete_ids:
|
||||||
|
return 0
|
||||||
|
con.executemany("delete from workflow_checkpoints where id=?", [(i,) for i in delete_ids])
|
||||||
|
return len(delete_ids)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_compact)
|
||||||
|
|
||||||
|
|
||||||
|
class OracleCheckpointRepository(LangGraphCheckpointRepository):
|
||||||
|
"""Checkpoint repository real para Oracle/Autonomous Database.
|
||||||
|
|
||||||
|
O OracleStore já cria as tabelas FIRST-compatible. A compactação é best-effort:
|
||||||
|
remove checkpoints antigos quando o store expõe conexão e prefixo de tabelas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
from agent_framework.persistence.oracle_store import OracleStore
|
||||||
|
|
||||||
|
self.store = OracleStore(settings)
|
||||||
|
|
||||||
|
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
|
||||||
|
await self.store.put_checkpoint(thread_id, checkpoint)
|
||||||
|
|
||||||
|
async def get_latest(self, thread_id: str):
|
||||||
|
return await self.store.get_latest_checkpoint(thread_id)
|
||||||
|
|
||||||
|
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
|
||||||
|
return await super().list_latest(thread_id, limit)
|
||||||
|
|
||||||
|
def _list():
|
||||||
|
sql = f"""
|
||||||
|
select CHECKPOINT_JSON
|
||||||
|
from {self.store.t('WORKFLOW_CHECKPOINT')}
|
||||||
|
where THREAD_ID = :thread_id
|
||||||
|
order by ID desc
|
||||||
|
fetch first :limit rows only
|
||||||
|
"""
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
rows = conn.cursor().execute(sql, dict(thread_id=thread_id, limit=int(limit))).fetchall()
|
||||||
|
return [_json_loads(r[0], None) for r in rows if r]
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_list)
|
||||||
|
|
||||||
|
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||||
|
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _compact():
|
||||||
|
table = self.store.t("WORKFLOW_CHECKPOINT")
|
||||||
|
sql_count = f"select count(*) from {table} where THREAD_ID = :thread_id"
|
||||||
|
sql_delete = f"""
|
||||||
|
delete from {table}
|
||||||
|
where THREAD_ID = :thread_id
|
||||||
|
and ID not in (
|
||||||
|
select ID from {table}
|
||||||
|
where THREAD_ID = :thread_id
|
||||||
|
order by ID desc
|
||||||
|
fetch first :keep_last rows only
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
with self.store.connect() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
before = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
|
||||||
|
cur.execute(sql_delete, dict(thread_id=thread_id, keep_last=int(keep_last)))
|
||||||
|
after = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
|
||||||
|
return max(0, before - after)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_compact)
|
||||||
|
|
||||||
|
|
||||||
|
AutonomousCheckpointRepository = OracleCheckpointRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientCheckpointRepository(LangGraphCheckpointRepository):
|
||||||
|
"""Adds integrity, retry, compaction and recovery to any repository.
|
||||||
|
|
||||||
|
This wrapper is intentionally repository-neutral. It can protect memory,
|
||||||
|
SQLite and Oracle repositories without changing LangGraph code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
inner: LangGraphCheckpointRepository,
|
||||||
|
*,
|
||||||
|
integrity: CheckpointIntegrityService | None = None,
|
||||||
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
enable_integrity: bool = True,
|
||||||
|
enable_compaction: bool = True,
|
||||||
|
compact_every: int = 50,
|
||||||
|
keep_last: int = 20,
|
||||||
|
recovery_scan_limit: int = 25,
|
||||||
|
):
|
||||||
|
self.inner = inner
|
||||||
|
self.integrity = integrity or CheckpointIntegrityService()
|
||||||
|
self.retry_policy = retry_policy or RetryPolicy()
|
||||||
|
self.enable_integrity = enable_integrity
|
||||||
|
self.enable_compaction = enable_compaction
|
||||||
|
self.compact_every = max(1, int(compact_every))
|
||||||
|
self.keep_last = max(1, int(keep_last))
|
||||||
|
self.recovery_scan_limit = max(1, int(recovery_scan_limit))
|
||||||
|
self._put_count_by_thread: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def _with_retry(self, operation_name: str, coro_factory):
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(1, self.retry_policy.max_attempts + 1):
|
||||||
|
try:
|
||||||
|
return await coro_factory()
|
||||||
|
except Exception as exc: # noqa: BLE001 - repository failures vary by backend
|
||||||
|
last_exc = exc
|
||||||
|
if attempt >= self.retry_policy.max_attempts:
|
||||||
|
break
|
||||||
|
delay = min(
|
||||||
|
self.retry_policy.max_delay_seconds,
|
||||||
|
self.retry_policy.base_delay_seconds * (2 ** (attempt - 1)),
|
||||||
|
) + random.uniform(0, self.retry_policy.jitter_seconds)
|
||||||
|
logger.warning("checkpoint.%s.retry attempt=%s delay=%.3fs error=%s", operation_name, attempt, delay, exc)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
raise last_exc # type: ignore[misc]
|
||||||
|
|
||||||
|
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None:
|
||||||
|
payload = self.integrity.wrap(thread_id, checkpoint) if self.enable_integrity else checkpoint
|
||||||
|
await self._with_retry("put", lambda: self.inner.put(thread_id, payload))
|
||||||
|
self._put_count_by_thread[thread_id] = self._put_count_by_thread.get(thread_id, 0) + 1
|
||||||
|
if self.enable_compaction and self._put_count_by_thread[thread_id] % self.compact_every == 0:
|
||||||
|
try:
|
||||||
|
removed = await self.inner.compact(thread_id, keep_last=self.keep_last)
|
||||||
|
if removed:
|
||||||
|
logger.info("checkpoint.compaction thread_id=%s removed=%s keep_last=%s", thread_id, removed, self.keep_last)
|
||||||
|
except Exception as exc: # compaction must never break the user flow
|
||||||
|
logger.warning("checkpoint.compaction.failed thread_id=%s error=%s", thread_id, exc)
|
||||||
|
|
||||||
|
async def get_latest(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
|
return await self.recover_latest(thread_id)
|
||||||
|
|
||||||
|
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
raw_items = await self.inner.list_latest(thread_id, limit)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for item in raw_items:
|
||||||
|
try:
|
||||||
|
payload = self.integrity.unwrap(item) if self.enable_integrity else item
|
||||||
|
if payload is not None:
|
||||||
|
out.append(payload)
|
||||||
|
except CheckpointIntegrityError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
|
||||||
|
return await self.inner.compact(thread_id, keep_last=keep_last)
|
||||||
|
|
||||||
|
async def recover_latest(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Return the newest valid LangGraph checkpoint, skipping corrupt or legacy records."""
|
||||||
|
raw_items = await self._with_retry(
|
||||||
|
"list_latest",
|
||||||
|
lambda: self.inner.list_latest(thread_id, self.recovery_scan_limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
first_integrity_error: Exception | None = None
|
||||||
|
invalid_count = 0
|
||||||
|
|
||||||
|
for raw in raw_items:
|
||||||
|
try:
|
||||||
|
payload = self.integrity.unwrap(raw)
|
||||||
|
|
||||||
|
candidate = payload
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(payload, dict)
|
||||||
|
and "checkpoint" in payload
|
||||||
|
):
|
||||||
|
candidate = payload["checkpoint"]
|
||||||
|
|
||||||
|
if not self.is_valid_checkpoint(candidate):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
except CheckpointIntegrityError as exc:
|
||||||
|
first_integrity_error = first_integrity_error or exc
|
||||||
|
logger.error(
|
||||||
|
"checkpoint.recovery.skip_corrupt thread_id=%s error=%s",
|
||||||
|
thread_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if first_integrity_error:
|
||||||
|
# No valid checkpoint: return None so the run starts clean instead of crashing ainvoke.
|
||||||
|
logger.error(
|
||||||
|
"checkpoint.recovery.no_valid_checkpoint thread_id=%s starting_fresh error=%s",
|
||||||
|
thread_id,
|
||||||
|
first_integrity_error,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if invalid_count:
|
||||||
|
logger.warning(
|
||||||
|
"checkpoint.recovery.no_valid_langgraph_checkpoint "
|
||||||
|
"thread_id=%s invalid_count=%s",
|
||||||
|
thread_id,
|
||||||
|
invalid_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _retry_policy_from_settings(settings) -> RetryPolicy:
|
||||||
|
return RetryPolicy(
|
||||||
|
max_attempts=int(getattr(settings, "CHECKPOINT_RETRY_MAX_ATTEMPTS", 3) or 3),
|
||||||
|
base_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_BASE_DELAY_SECONDS", 0.05) or 0.05),
|
||||||
|
max_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_MAX_DELAY_SECONDS", 1.0) or 1.0),
|
||||||
|
jitter_seconds=float(getattr(settings, "CHECKPOINT_RETRY_JITTER_SECONDS", 0.05) or 0.05),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_raw_checkpoint_repository(settings):
|
||||||
|
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
|
||||||
|
if provider == "sqlite":
|
||||||
|
return SQLiteCheckpointRepository(settings)
|
||||||
|
if provider in {"autonomous", "oracle"}:
|
||||||
|
return OracleCheckpointRepository(settings)
|
||||||
|
return InMemoryCheckpointRepository()
|
||||||
|
|
||||||
|
|
||||||
|
def create_checkpoint_repository(settings):
|
||||||
|
raw = create_raw_checkpoint_repository(settings)
|
||||||
|
if not bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True)):
|
||||||
|
return raw
|
||||||
|
return ResilientCheckpointRepository(
|
||||||
|
raw,
|
||||||
|
retry_policy=_retry_policy_from_settings(settings),
|
||||||
|
enable_integrity=bool(getattr(settings, "ENABLE_CHECKPOINT_INTEGRITY", True)),
|
||||||
|
enable_compaction=bool(getattr(settings, "ENABLE_CHECKPOINT_COMPACTION", True)),
|
||||||
|
compact_every=int(getattr(settings, "CHECKPOINT_COMPACT_EVERY", 50) or 50),
|
||||||
|
keep_last=int(getattr(settings, "CHECKPOINT_KEEP_LAST", 20) or 20),
|
||||||
|
recovery_scan_limit=int(getattr(settings, "CHECKPOINT_RECOVERY_SCAN_LIMIT", 25) or 25),
|
||||||
|
)
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
try:
|
||||||
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||||
|
except Exception: # pragma: no cover - fallback for lightweight unit tests without langgraph installed
|
||||||
|
class BaseCheckpointSaver: # type: ignore[no-redef]
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""LangGraph checkpoint saver backed by the framework checkpoint repository.
|
||||||
|
|
||||||
|
This module intentionally keeps a small adapter surface so the framework can run
|
||||||
|
with multiple LangGraph versions. It implements the common synchronous and
|
||||||
|
asynchronous methods used by BaseCheckpointSaver/MemorySaver: get_tuple,
|
||||||
|
aget_tuple, put, aput, put_writes, aput_writes, list and alist.
|
||||||
|
|
||||||
|
The persisted payload stores LangGraph's raw checkpoint/config/metadata values in
|
||||||
|
repository-neutral JSON. When LangGraph is installed, checkpoint tuples are
|
||||||
|
returned using CheckpointTuple; otherwise a simple dict is returned for tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, AsyncIterator, Iterator
|
||||||
|
|
||||||
|
from .checkpoint_repository import create_checkpoint_repository
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_legacy_json_container(value: Any, expected: type) -> Any:
|
||||||
|
"""Recover containers that older JSON backends persisted as JSON strings.
|
||||||
|
|
||||||
|
This is intentionally field-scoped: ordinary business strings must stay
|
||||||
|
strings, even if their text happens to look like JSON.
|
||||||
|
"""
|
||||||
|
current = value
|
||||||
|
for _ in range(3):
|
||||||
|
if isinstance(current, expected):
|
||||||
|
return current
|
||||||
|
if not isinstance(current, str):
|
||||||
|
break
|
||||||
|
text = current.strip()
|
||||||
|
if not text:
|
||||||
|
break
|
||||||
|
if expected is dict and not text.startswith("{"):
|
||||||
|
break
|
||||||
|
if expected is list and not text.startswith("["):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
current = json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
return current if isinstance(current, expected) else expected()
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_json_value(value: Any, *, path: str = "$") -> Any:
|
||||||
|
"""Convert to repository-safe JSON without ever falling back to ``str``.
|
||||||
|
|
||||||
|
``default=str`` is unsafe for LangGraph checkpoints: runtime/task objects can
|
||||||
|
become ordinary strings and later be consumed as typed values by Pregel.
|
||||||
|
Keep native JSON containers recursively and fail loudly for an unsupported
|
||||||
|
object instead of corrupting it silently.
|
||||||
|
"""
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key): _strict_json_value(item, path=f"{path}.{key}")
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [
|
||||||
|
_strict_json_value(item, path=f"{path}[{idx}]")
|
||||||
|
for idx, item in enumerate(value)
|
||||||
|
]
|
||||||
|
# Common durable scalar types that JSON does not know natively.
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return str(value)
|
||||||
|
try:
|
||||||
|
from datetime import date, datetime
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return value.isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from enum import Enum
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return _strict_json_value(value.value, path=path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||||
|
return _strict_json_value(value.model_dump(), path=path)
|
||||||
|
raise TypeError(
|
||||||
|
f"Checkpoint contém valor não serializável em {path}: "
|
||||||
|
f"{type(value).__module__}.{type(value).__qualname__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_checkpoint(checkpoint: Any) -> dict[str, Any]:
|
||||||
|
checkpoint = _parse_legacy_json_container(checkpoint, dict)
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
return {}
|
||||||
|
out = dict(checkpoint)
|
||||||
|
out["channel_values"] = _parse_legacy_json_container(out.get("channel_values"), dict)
|
||||||
|
out["channel_versions"] = _parse_legacy_json_container(out.get("channel_versions"), dict)
|
||||||
|
raw_seen = _parse_legacy_json_container(out.get("versions_seen"), dict)
|
||||||
|
out["versions_seen"] = {
|
||||||
|
str(node): _parse_legacy_json_container(versions, dict)
|
||||||
|
for node, versions in raw_seen.items()
|
||||||
|
}
|
||||||
|
if "pending_sends" in out:
|
||||||
|
out["pending_sends"] = _parse_legacy_json_container(out.get("pending_sends"), list)
|
||||||
|
if "updated_channels" in out and isinstance(out.get("updated_channels"), str):
|
||||||
|
out["updated_channels"] = _parse_legacy_json_container(out.get("updated_channels"), list)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_metadata(metadata: Any) -> dict[str, Any]:
|
||||||
|
value = _parse_legacy_json_container(metadata, dict)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config(config: Any) -> dict[str, Any]:
|
||||||
|
value = _parse_legacy_json_container(config, dict)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
out = dict(value)
|
||||||
|
out["configurable"] = _parse_legacy_json_container(out.get("configurable"), dict)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_EPHEMERAL_RUNTIME_KEYS = {"__pregel_runtime", "__pregel_store"}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_runtime_refs(value: Any) -> Any:
|
||||||
|
"""Recursively remove process-local runtime/store references only.
|
||||||
|
|
||||||
|
Checkpoints may legitimately contain LangGraph internal channels whose names
|
||||||
|
also start with ``__pregel_`` (for example task channels). Those are durable
|
||||||
|
graph state and must be preserved. The corruption that triggers
|
||||||
|
``str.override`` is specifically a runtime/store object captured inside a
|
||||||
|
nested RunnableConfig and later stringified by the JSON repository.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _strip_runtime_refs(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if str(key) not in _EPHEMERAL_RUNTIME_KEYS
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_strip_runtime_refs(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(_strip_runtime_refs(item) for item in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _durable_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Return a checkpoint-safe copy of a LangGraph RunnableConfig.
|
||||||
|
|
||||||
|
LangGraph injects ephemeral private values such as ``__pregel_runtime`` and
|
||||||
|
``__pregel_store`` under ``configurable`` while a graph is running. They are
|
||||||
|
process-local and must never cross the durable checkpoint boundary.
|
||||||
|
|
||||||
|
The scrub is recursive because task/pending-write config fragments may be
|
||||||
|
nested below regular config fields in newer LangGraph versions.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return {}
|
||||||
|
cleaned = _strip_runtime_refs(config)
|
||||||
|
if not isinstance(cleaned, dict):
|
||||||
|
return {}
|
||||||
|
configurable = cleaned.get("configurable")
|
||||||
|
if isinstance(configurable, dict):
|
||||||
|
cleaned = dict(cleaned)
|
||||||
|
cleaned["configurable"] = {
|
||||||
|
key: value
|
||||||
|
for key, value in configurable.items()
|
||||||
|
if not str(key).startswith("__pregel_")
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_checkpoint_config(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
request_config: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Rebuild the RunnableConfig returned to LangGraph from durable IDs only.
|
||||||
|
|
||||||
|
Official LangGraph savers do not re-bind the full config that happened to be
|
||||||
|
present when a checkpoint was written. They reconstruct a fresh config from
|
||||||
|
``thread_id``, ``checkpoint_ns`` and ``checkpoint_id``. Doing the same here
|
||||||
|
prevents a historical/factory-time runtime value from being rebound into a
|
||||||
|
new execution while remaining backward compatible with existing rows.
|
||||||
|
"""
|
||||||
|
requested = _durable_config(request_config)
|
||||||
|
stored = _durable_config(_normalize_config(payload.get("config")) if isinstance(payload, dict) else None)
|
||||||
|
req_cfg = requested.get("configurable") if isinstance(requested.get("configurable"), dict) else {}
|
||||||
|
stored_cfg = stored.get("configurable") if isinstance(stored.get("configurable"), dict) else {}
|
||||||
|
checkpoint = payload.get("checkpoint") if isinstance(payload, dict) else {}
|
||||||
|
checkpoint = checkpoint if isinstance(checkpoint, dict) else {}
|
||||||
|
|
||||||
|
thread_id = (
|
||||||
|
req_cfg.get("thread_id")
|
||||||
|
or stored_cfg.get("thread_id")
|
||||||
|
or payload.get("thread_id")
|
||||||
|
or "default"
|
||||||
|
)
|
||||||
|
checkpoint_ns = req_cfg.get("checkpoint_ns")
|
||||||
|
if checkpoint_ns is None:
|
||||||
|
checkpoint_ns = stored_cfg.get("checkpoint_ns", "")
|
||||||
|
|
||||||
|
requested_checkpoint_id = req_cfg.get("checkpoint_id")
|
||||||
|
checkpoint_id = (
|
||||||
|
requested_checkpoint_id
|
||||||
|
or payload.get("checkpoint_id")
|
||||||
|
or checkpoint.get("id")
|
||||||
|
or stored_cfg.get("checkpoint_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
configurable: dict[str, Any] = {
|
||||||
|
"thread_id": str(thread_id),
|
||||||
|
"checkpoint_ns": str(checkpoint_ns or ""),
|
||||||
|
}
|
||||||
|
if checkpoint_id not in (None, ""):
|
||||||
|
configurable["checkpoint_id"] = str(checkpoint_id)
|
||||||
|
return {"configurable": configurable}
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_id(config: dict[str, Any] | None) -> str:
|
||||||
|
configurable = (config or {}).get("configurable") or {}
|
||||||
|
return str(configurable.get("thread_id") or configurable.get("checkpoint_ns") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint_id(checkpoint: dict[str, Any] | None) -> str:
|
||||||
|
if isinstance(checkpoint, dict):
|
||||||
|
return str(checkpoint.get("id") or checkpoint.get("checkpoint_id") or uuid.uuid4())
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_pending_writes(pending_writes: Any) -> list[tuple[Any, Any, Any]]:
|
||||||
|
"""Normalize persisted pending_writes to LangGraph's expected runtime format.
|
||||||
|
|
||||||
|
LangGraph 1.1.x expects CheckpointTuple.pending_writes to be an iterable of
|
||||||
|
3-item tuples: (task_id, channel, value).
|
||||||
|
|
||||||
|
Older framework versions persisted writes as dictionaries containing
|
||||||
|
task_id, task_path, channel and value. Some stores/tests may also contain
|
||||||
|
4-item tuples: (task_id, task_path, channel, value). This adapter accepts
|
||||||
|
those legacy forms while preserving already-correct 3-item tuples.
|
||||||
|
"""
|
||||||
|
normalized: list[tuple[Any, Any, Any]] = []
|
||||||
|
for item in pending_writes or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
normalized.append((
|
||||||
|
item.get("task_id"),
|
||||||
|
item.get("channel"),
|
||||||
|
item.get("value"),
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(item, (list, tuple)):
|
||||||
|
if len(item) == 3:
|
||||||
|
task_id, channel, value = item
|
||||||
|
normalized.append((task_id, channel, value))
|
||||||
|
continue
|
||||||
|
if len(item) == 4:
|
||||||
|
task_id, _task_path, channel, value = item
|
||||||
|
normalized.append((task_id, channel, value))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Defensive fallback: keep malformed legacy entries from crashing resume.
|
||||||
|
# Use a synthetic channel so the data remains inspectable in telemetry/logs.
|
||||||
|
normalized.append((None, "__malformed_pending_write__", item))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryCheckpointSaver(BaseCheckpointSaver):
|
||||||
|
"""Checkpoint saver nativo para LangGraph usando os repositories do framework."""
|
||||||
|
|
||||||
|
def __init__(self, settings, repository=None):
|
||||||
|
super().__init__()
|
||||||
|
self.settings = settings
|
||||||
|
self.repository = repository or create_checkpoint_repository(settings)
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
def _run(self, coro):
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro)
|
||||||
|
# LangGraph may call sync methods from a worker thread; when already in
|
||||||
|
# an event loop prefer a short-lived thread to avoid nested-loop errors.
|
||||||
|
import concurrent.futures
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||||||
|
return ex.submit(lambda: asyncio.run(coro)).result()
|
||||||
|
|
||||||
|
def _make_tuple(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any] | None,
|
||||||
|
request_config: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
# Second-stage protection: never re-bind the full persisted RunnableConfig.
|
||||||
|
# Rebuild only the durable identifiers, as official LangGraph savers do.
|
||||||
|
config = _canonical_checkpoint_config(payload, request_config)
|
||||||
|
checkpoint = _strip_runtime_refs(_normalize_checkpoint(payload.get("checkpoint") or {}))
|
||||||
|
metadata = _strip_runtime_refs(_normalize_metadata(payload.get("metadata") or {}))
|
||||||
|
raw_parent_config = payload.get("parent_config")
|
||||||
|
if isinstance(raw_parent_config, dict):
|
||||||
|
parent_payload = {
|
||||||
|
"thread_id": payload.get("thread_id"),
|
||||||
|
"config": raw_parent_config,
|
||||||
|
"checkpoint_id": (raw_parent_config.get("configurable") or {}).get("checkpoint_id")
|
||||||
|
if isinstance(raw_parent_config.get("configurable"), dict)
|
||||||
|
else None,
|
||||||
|
"checkpoint": {},
|
||||||
|
}
|
||||||
|
parent_config = _canonical_checkpoint_config(parent_payload)
|
||||||
|
else:
|
||||||
|
parent_config = None
|
||||||
|
pending_writes = _normalize_pending_writes(
|
||||||
|
_strip_runtime_refs(payload.get("pending_writes") or [])
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from langgraph.checkpoint.base import CheckpointTuple
|
||||||
|
return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes)
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"config": _durable_config(config),
|
||||||
|
"checkpoint": checkpoint,
|
||||||
|
"metadata": metadata,
|
||||||
|
"parent_config": parent_config,
|
||||||
|
"pending_writes": pending_writes,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def aget_tuple(self, config: dict[str, Any]):
|
||||||
|
return self._make_tuple(
|
||||||
|
await self.repository.get_latest(_thread_id(config)),
|
||||||
|
request_config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_tuple(self, config: dict[str, Any]):
|
||||||
|
return self._run(self.aget_tuple(config))
|
||||||
|
|
||||||
|
async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
|
||||||
|
thread_id = _thread_id(config)
|
||||||
|
checkpoint_id = _checkpoint_id(checkpoint)
|
||||||
|
clean_config = _durable_config(config)
|
||||||
|
clean_cfg = clean_config.get("configurable") if isinstance(clean_config.get("configurable"), dict) else {}
|
||||||
|
checkpoint_ns = str(clean_cfg.get("checkpoint_ns") or "")
|
||||||
|
# Return a fresh canonical config. Never feed process-local/factory-time
|
||||||
|
# configurable values back into the next LangGraph super-step.
|
||||||
|
next_config = {
|
||||||
|
"configurable": {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"checkpoint_ns": checkpoint_ns,
|
||||||
|
"checkpoint_id": checkpoint_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.repository.put(thread_id, {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"config": _strict_json_value(next_config, path="$.config"),
|
||||||
|
"checkpoint": _strict_json_value(_strip_runtime_refs(_normalize_checkpoint(checkpoint)), path="$.checkpoint"),
|
||||||
|
"metadata": _strict_json_value(_strip_runtime_refs(_normalize_metadata(metadata or {})), path="$.metadata"),
|
||||||
|
"new_versions": _strict_json_value(_strip_runtime_refs(new_versions or {}), path="$.new_versions"),
|
||||||
|
"checkpoint_id": checkpoint_id,
|
||||||
|
})
|
||||||
|
return next_config
|
||||||
|
|
||||||
|
def put(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
|
||||||
|
return self._run(self.aput(config, checkpoint, metadata, new_versions))
|
||||||
|
|
||||||
|
async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
|
||||||
|
thread_id = _thread_id(config)
|
||||||
|
try:
|
||||||
|
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": _durable_config(config), "checkpoint": {}, "metadata": {}}
|
||||||
|
except:
|
||||||
|
latest = {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"config": _durable_config(config),
|
||||||
|
"checkpoint": {},
|
||||||
|
"metadata": {},
|
||||||
|
"pending_writes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(latest, dict):
|
||||||
|
# Do not keep extending a persisted RunnableConfig across super-steps.
|
||||||
|
# Rebuild the same canonical config that aget_tuple() will expose.
|
||||||
|
latest["config"] = _canonical_checkpoint_config(latest, config)
|
||||||
|
if isinstance(latest.get("checkpoint"), dict):
|
||||||
|
latest["checkpoint"] = _strip_runtime_refs(latest.get("checkpoint"))
|
||||||
|
if isinstance(latest.get("metadata"), dict):
|
||||||
|
latest["metadata"] = _strip_runtime_refs(latest.get("metadata"))
|
||||||
|
if isinstance(latest.get("parent_config"), dict):
|
||||||
|
parent_payload = {
|
||||||
|
"thread_id": latest.get("thread_id") or thread_id,
|
||||||
|
"config": latest.get("parent_config"),
|
||||||
|
"checkpoint_id": (latest.get("parent_config", {}).get("configurable") or {}).get("checkpoint_id")
|
||||||
|
if isinstance(latest.get("parent_config", {}).get("configurable"), dict)
|
||||||
|
else None,
|
||||||
|
"checkpoint": {},
|
||||||
|
}
|
||||||
|
latest["parent_config"] = _canonical_checkpoint_config(parent_payload)
|
||||||
|
|
||||||
|
pending = list(latest.get("pending_writes") or [])
|
||||||
|
for channel, value in writes or []:
|
||||||
|
# Writes may contain nested task/RunnableConfig fragments. Scrub the
|
||||||
|
# private runtime before the repository's JSON ``default=str`` layer.
|
||||||
|
durable_value = _strip_runtime_refs(value)
|
||||||
|
pending.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_path": task_path,
|
||||||
|
"channel": channel,
|
||||||
|
"value": _strict_json_value(durable_value, path=f"$.pending_writes[{task_id}].{channel}"),
|
||||||
|
})
|
||||||
|
latest["pending_writes"] = pending
|
||||||
|
await self.repository.put(thread_id, latest)
|
||||||
|
|
||||||
|
def put_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
|
||||||
|
return self._run(self.aput_writes(config, writes, task_id, task_path))
|
||||||
|
|
||||||
|
async def alist(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> AsyncIterator[Any]:
|
||||||
|
# Repository interface currently exposes only latest; this is enough for
|
||||||
|
# resume/recovery. Oracle/SQLite repositories can later implement full list.
|
||||||
|
if config is None:
|
||||||
|
return
|
||||||
|
item = await self.aget_tuple(config)
|
||||||
|
if item:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
def list(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> Iterator[Any]:
|
||||||
|
item = self.get_tuple(config or {}) if config else None
|
||||||
|
if item:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
def create_langgraph_checkpointer(settings):
|
||||||
|
"""Factory used by applications when compiling LangGraph.
|
||||||
|
|
||||||
|
By default the framework now returns RepositoryCheckpointSaver even for
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER=memory, because the repository wrapper adds
|
||||||
|
integrity checks, retry, recovery and compaction.
|
||||||
|
|
||||||
|
Set ENABLE_RESILIENT_CHECKPOINTER=false to fall back to LangGraph MemorySaver
|
||||||
|
for very small local experiments.
|
||||||
|
"""
|
||||||
|
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
|
||||||
|
resilient = bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True))
|
||||||
|
if provider == "memory" and not resilient:
|
||||||
|
try:
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
return MemorySaver()
|
||||||
|
except Exception:
|
||||||
|
return RepositoryCheckpointSaver(settings)
|
||||||
|
return RepositoryCheckpointSaver(settings)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
yaml = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentProfile:
|
||||||
|
agent_id: str
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
prompt_policy_path: str | None = None
|
||||||
|
routing_config_path: str | None = None
|
||||||
|
guardrails_config_path: str | None = None
|
||||||
|
judges_config_path: str | None = None
|
||||||
|
mcp_servers_config_path: str | None = None
|
||||||
|
tools_config_path: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentProfileRegistry:
|
||||||
|
"""Carrega perfis de agentes/templates a partir de YAML.
|
||||||
|
|
||||||
|
O objetivo é permitir múltiplos agent_template no mesmo backend sem misturar
|
||||||
|
memória, checkpoints, prompts, guardrails ou judges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.base_dir = Path.cwd()
|
||||||
|
self.profiles: dict[str, AgentProfile] = {}
|
||||||
|
self.default_agent_id = "default_agent"
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _resolve(self, value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
path = Path(value)
|
||||||
|
return str(path if path.is_absolute() else (self.base_dir / path).resolve())
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
config_path = Path(getattr(self.settings, "AGENTS_CONFIG_PATH", "./config/agents.yaml"))
|
||||||
|
if not config_path.is_absolute():
|
||||||
|
config_path = self.base_dir / config_path
|
||||||
|
if not config_path.exists() or yaml is None:
|
||||||
|
self.profiles[self.default_agent_id] = AgentProfile(
|
||||||
|
agent_id=self.default_agent_id,
|
||||||
|
name="Default Agent",
|
||||||
|
prompt_policy_path=self._resolve(getattr(self.settings, "PROMPT_POLICY_PATH", None)),
|
||||||
|
routing_config_path=self._resolve(getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
|
||||||
|
guardrails_config_path=self._resolve(getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
|
||||||
|
judges_config_path=self._resolve(getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
|
||||||
|
mcp_servers_config_path=self._resolve(getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
|
||||||
|
tools_config_path=self._resolve(getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
|
self.default_agent_id = raw.get("default_agent_id") or self.default_agent_id
|
||||||
|
for item in raw.get("agents", []):
|
||||||
|
agent_id = str(item.get("agent_id") or item.get("id") or "").strip()
|
||||||
|
if not agent_id:
|
||||||
|
continue
|
||||||
|
self.profiles[agent_id] = AgentProfile(
|
||||||
|
agent_id=agent_id,
|
||||||
|
name=item.get("name", agent_id),
|
||||||
|
description=item.get("description", ""),
|
||||||
|
prompt_policy_path=self._resolve(item.get("prompt_policy_path") or getattr(self.settings, "PROMPT_POLICY_PATH", None)),
|
||||||
|
routing_config_path=self._resolve(item.get("routing_config_path") or getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
|
||||||
|
guardrails_config_path=self._resolve(item.get("guardrails_config_path") or getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
|
||||||
|
judges_config_path=self._resolve(item.get("judges_config_path") or getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
|
||||||
|
mcp_servers_config_path=self._resolve(item.get("mcp_servers_config_path") or getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
|
||||||
|
tools_config_path=self._resolve(item.get("tools_config_path") or getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
|
||||||
|
metadata=item.get("metadata") or {},
|
||||||
|
)
|
||||||
|
if self.default_agent_id not in self.profiles and self.profiles:
|
||||||
|
self.default_agent_id = next(iter(self.profiles))
|
||||||
|
|
||||||
|
def get(self, agent_id: str | None = None) -> AgentProfile:
|
||||||
|
key = agent_id or self.default_agent_id
|
||||||
|
return self.profiles.get(key) or self.profiles[self.default_agent_id]
|
||||||
|
|
||||||
|
def list_profiles(self) -> list[AgentProfile]:
|
||||||
|
return list(self.profiles.values())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
# Default compatibility registry shipped with agent_framework_oci.
|
||||||
|
#
|
||||||
|
# This file reproduces the historical behavior that used to be hardcoded in
|
||||||
|
# OutputSupervisor / ParallelRailExecutor. It is ALWAYS loaded by the framework.
|
||||||
|
# An agent/deployment observability_mapping.yaml is then applied as an overlay.
|
||||||
|
#
|
||||||
|
# Therefore an older agent can replace only the framework and keep the same
|
||||||
|
# GRL contract and legacy guardrail actions without adding new configuration.
|
||||||
|
mappings:
|
||||||
|
# Historical OutputSupervisor taxonomy.
|
||||||
|
guardrail.output_supervisor.started:
|
||||||
|
label: GRL.001
|
||||||
|
guardrail.result.allow:
|
||||||
|
label: GRL.002
|
||||||
|
guardrail.result.sanitize:
|
||||||
|
label: GRL.003
|
||||||
|
guardrail.result.block:
|
||||||
|
label: GRL.004
|
||||||
|
guardrail.result.retry:
|
||||||
|
label: GRL.005
|
||||||
|
guardrail.result.handover:
|
||||||
|
label: GRL.006
|
||||||
|
guardrail.result.observe:
|
||||||
|
label: GRL.007
|
||||||
|
guardrail.fail_closed:
|
||||||
|
label: GRL.008
|
||||||
|
guardrail.output_supervisor.completed:
|
||||||
|
label: GRL.009
|
||||||
|
|
||||||
|
# Named guardrail events historically emitted as GRL.<RAIL_CODE>.
|
||||||
|
guardrail.input_size: {label: GRL.INPUT_SIZE, aliases: [INPUT_SIZE, SIZE]}
|
||||||
|
guardrail.msk: {label: GRL.MSK, aliases: [MSK, PII]}
|
||||||
|
guardrail.tox: {label: GRL.TOX, aliases: [TOX]}
|
||||||
|
guardrail.pinj: {label: GRL.PINJ, aliases: [PINJ]}
|
||||||
|
guardrail.jailbreak: {label: GRL.JAILBREAK, aliases: [JAILBREAK]}
|
||||||
|
guardrail.vloop: {label: GRL.VLOOP, aliases: [VLOOP, LOOP]}
|
||||||
|
guardrail.dlex_in: {label: GRL.DLEX_IN, aliases: [DLEX_IN]}
|
||||||
|
guardrail.oos: {label: GRL.OOS, aliases: [OOS]}
|
||||||
|
guardrail.coer: {label: GRL.COER, aliases: [COER]}
|
||||||
|
guardrail.msk_out: {label: GRL.MSK_OUT, aliases: [MSK_OUT, OUTPUT_MSK]}
|
||||||
|
guardrail.toxout: {label: GRL.TOXOUT, aliases: [TOXOUT, TOX_OUT]}
|
||||||
|
guardrail.aoferta: {label: GRL.AOFERTA, aliases: [AOFERTA, PROACTIVE_OFFER]}
|
||||||
|
guardrail.dlex_out: {label: GRL.DLEX_OUT, aliases: [DLEX_OUT]}
|
||||||
|
guardrail.aluc_risk: {label: GRL.ALUC_RISK, aliases: [ALUC_RISK, HALLUCINATION_RISK]}
|
||||||
|
guardrail.ret_rel: {label: GRL.RET_REL, aliases: [RET_REL, RETRIEVAL_RELEVANCE]}
|
||||||
|
guardrail.ragsec: {label: GRL.RAGSEC, aliases: [RAGSEC]}
|
||||||
|
guardrail.tool_val: {label: GRL.TOOL_VAL, aliases: [TOOL_VAL, TOOL_VALIDATION]}
|
||||||
|
|
||||||
|
# Historical action-by-name behavior, now declarative.
|
||||||
|
guardrail.revprec:
|
||||||
|
label: GRL.REVPREC
|
||||||
|
action: retry
|
||||||
|
aliases: [REVPREC, PREMATURE_ACTION]
|
||||||
|
guardrail.cmp:
|
||||||
|
label: GRL.CMP
|
||||||
|
action: retry
|
||||||
|
aliases: [CMP, COMPLIANCE]
|
||||||
|
guardrail.sco:
|
||||||
|
label: GRL.SCO
|
||||||
|
action: retry
|
||||||
|
aliases: [SCO]
|
||||||
|
guardrail.gnd:
|
||||||
|
label: GRL.GND
|
||||||
|
action: retry
|
||||||
|
aliases: [GND, GROUNDEDNESS]
|
||||||
|
guardrail.handover:
|
||||||
|
action: handover
|
||||||
|
aliases: [HANDOVER, ATH, HUMAN]
|
||||||
|
|
||||||
|
# Historical FRASEOLOGIA special-case rewrite, now capability-driven.
|
||||||
|
guardrail.fraseologia:
|
||||||
|
label: GRL.FRASEOLOGIA
|
||||||
|
aliases: [FRASEOLOGIA]
|
||||||
|
remediation:
|
||||||
|
type: rewrite
|
||||||
|
max_attempts: 1
|
||||||
|
prompt_id: FALLBACK
|
||||||
|
profile_name: grl
|
||||||
|
component_name: guardrail.fraseologia.rewrite
|
||||||
|
generation_name: guardrail.fraseologia.rewrite
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# Load .env into os.environ as well.
|
||||||
|
# Pydantic Settings reads .env for Settings fields, but parts of the calibrated
|
||||||
|
# guardrails intentionally use os.getenv for compatibility with the original
|
||||||
|
# guardrails package. Loading here keeps both paths consistent.
|
||||||
|
load_dotenv(override=False)
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
|
||||||
|
|
||||||
|
APP_NAME: str = 'ai-agent-template'
|
||||||
|
APP_ENV: str = 'local'
|
||||||
|
LOG_LEVEL: str = 'INFO'
|
||||||
|
API_HOST: str = '0.0.0.0'
|
||||||
|
API_PORT: int = 8000
|
||||||
|
CORS_ORIGINS: str = 'http://localhost:5173'
|
||||||
|
|
||||||
|
LLM_PROVIDER: Literal['mock','oci_openai','oci_sdk','openai_compatible'] = 'mock'
|
||||||
|
LLM_TEMPERATURE: float = 0.2
|
||||||
|
LLM_MAX_TOKENS: int = 2048
|
||||||
|
LLM_TIMEOUT_SECONDS: int = 120
|
||||||
|
LLM_PROFILES_PATH: str = './llm_profiles.yaml'
|
||||||
|
# Reasoning controls. When absent from .env, auto is the default.
|
||||||
|
# auto = enable only when the provider/model capability resolver says it is supported.
|
||||||
|
# true = force-enable (the provider still performs SDK/request safety checks).
|
||||||
|
# false = never send reasoning_effort.
|
||||||
|
LLM_REASONING_ENABLED: Literal['auto','true','false'] = 'auto'
|
||||||
|
LLM_REASONING_EFFORT: str | None = None
|
||||||
|
|
||||||
|
OCI_GENAI_BASE_URL: str = ''
|
||||||
|
OCI_GENAI_MODEL: str = 'openai.gpt-4.1'
|
||||||
|
OCI_GENAI_API_KEY: str | None = None
|
||||||
|
OCI_GENAI_PROJECT_OCID: str | None = None
|
||||||
|
# OCI SDK authentication mode.
|
||||||
|
# config_file = ~/.oci/config profile (default/local development)
|
||||||
|
# instance_principal = OCI Instance Principal signer (Compute/OKE without API key)
|
||||||
|
# resource_principal = OCI Resource Principal signer (Functions/resource principal contexts)
|
||||||
|
OCI_AUTH_MODE: Literal['config_file','instance_principal','resource_principal', 'oke_workload_identity'] = 'config_file'
|
||||||
|
OCI_CONFIG_FILE: str = '~/.oci/config'
|
||||||
|
OCI_PROFILE: str = 'DEFAULT'
|
||||||
|
OCI_COMPARTMENT_ID: str | None = None
|
||||||
|
OCI_REGION: str = ''
|
||||||
|
OCI_GENAI_ENDPOINT: str | None = None
|
||||||
|
OCI_EMBEDDING_ENDPOINT: str | None = None
|
||||||
|
|
||||||
|
SESSION_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||||
|
MEMORY_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||||
|
CHECKPOINT_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||||
|
|
||||||
|
# ConversationSummaryMemory: compressão de contexto conversacional.
|
||||||
|
# none = não injeta histórico no prompt
|
||||||
|
# window = injeta somente últimas mensagens
|
||||||
|
# summary = resumo acumulado + últimas mensagens completas
|
||||||
|
ENABLE_CONVERSATION_SUMMARY_MEMORY: bool = False
|
||||||
|
MEMORY_CONTEXT_STRATEGY: Literal['none','window','summary'] = 'window'
|
||||||
|
MEMORY_HISTORY_LIMIT: int = 80
|
||||||
|
MEMORY_RECENT_MESSAGES_LIMIT: int = 8
|
||||||
|
MEMORY_SUMMARY_TRIGGER_MESSAGES: int = 20
|
||||||
|
MEMORY_MAX_SUMMARY_CHARS: int = 6000
|
||||||
|
MEMORY_SUMMARY_USE_LLM: bool = True
|
||||||
|
MEMORY_INJECT_RECENT_MESSAGES: bool = True
|
||||||
|
MEMORY_INJECT_SUMMARY: bool = True
|
||||||
|
|
||||||
|
ENABLE_LONG_TERM_MEMORY: bool = False
|
||||||
|
LONG_TERM_MEMORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'sqlite'
|
||||||
|
LONG_TERM_MEMORY_SQLITE_PATH: str | None = None
|
||||||
|
LONG_TERM_MEMORY_TABLE: str = 'agentfw_long_term_memory'
|
||||||
|
LONG_TERM_MEMORY_ORACLE_TABLE: str | None = None
|
||||||
|
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS: int = 20
|
||||||
|
LONG_TERM_MEMORY_MIN_CONFIDENCE: float = 0.70
|
||||||
|
LONG_TERM_MEMORY_AUTO_EXTRACT: bool = True
|
||||||
|
LONG_TERM_MEMORY_INJECT_CONTEXT: bool = True
|
||||||
|
|
||||||
|
# LangGraph enterprise checkpointing
|
||||||
|
ENABLE_RESILIENT_CHECKPOINTER: bool = True
|
||||||
|
ENABLE_CHECKPOINT_INTEGRITY: bool = True
|
||||||
|
ENABLE_CHECKPOINT_COMPACTION: bool = True
|
||||||
|
CHECKPOINT_COMPACT_EVERY: int = 50
|
||||||
|
CHECKPOINT_KEEP_LAST: int = 20
|
||||||
|
CHECKPOINT_RECOVERY_SCAN_LIMIT: int = 25
|
||||||
|
CHECKPOINT_RETRY_MAX_ATTEMPTS: int = 3
|
||||||
|
CHECKPOINT_RETRY_BASE_DELAY_SECONDS: float = 0.05
|
||||||
|
CHECKPOINT_RETRY_MAX_DELAY_SECONDS: float = 1.0
|
||||||
|
CHECKPOINT_RETRY_JITTER_SECONDS: float = 0.05
|
||||||
|
USAGE_REPOSITORY_PROVIDER: Literal['sqlite','autonomous','oracle'] = 'sqlite'
|
||||||
|
|
||||||
|
ADB_USER: str | None = None
|
||||||
|
ADB_PASSWORD: str | None = None
|
||||||
|
ADB_DSN: str | None = None
|
||||||
|
ADB_WALLET_LOCATION: str | None = None
|
||||||
|
ADB_WALLET_PASSWORD: str | None = None
|
||||||
|
ADB_TABLE_PREFIX: str = 'AGENTFW'
|
||||||
|
|
||||||
|
MONGODB_URI: str = 'mongodb://localhost:27017'
|
||||||
|
MONGODB_DATABASE: str = 'agent_platform'
|
||||||
|
REDIS_URL: str = 'redis://localhost:6379/0'
|
||||||
|
ENABLE_REDIS_CACHE: bool = False
|
||||||
|
CACHE_KEY_PREFIX: str = 'agentfw'
|
||||||
|
|
||||||
|
VECTOR_STORE_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
|
||||||
|
GRAPH_STORE_PROVIDER: Literal['memory','autonomous','oracle'] = 'memory'
|
||||||
|
ORACLE_GRAPH_NAME: str = 'AGENTFW_GRAPH'
|
||||||
|
ORACLE_GRAPH_AUTO_CREATE: bool = False
|
||||||
|
RAG_TOP_K: int = 5
|
||||||
|
SKIP_RAG_WHEN_MCP_SUFFICIENT: bool = True
|
||||||
|
ENABLE_RAG_QUERY_REWRITE: bool = False
|
||||||
|
ENABLE_RAG_CONTEXT_COMPRESSION: bool = False
|
||||||
|
ENABLE_RAG_GENERATION: bool = False
|
||||||
|
EMBEDDING_PROVIDER: Literal['mock','oci'] = 'mock'
|
||||||
|
OCI_EMBEDDING_MODEL: str = 'cohere.embed-multilingual-v3.0'
|
||||||
|
|
||||||
|
ENABLE_LANGFUSE: bool = False
|
||||||
|
LANGFUSE_TRACE_MODE: Literal['verbose','compact'] = 'verbose'
|
||||||
|
LANGFUSE_ROOT_SPAN_NAME: str = 'agent.gateway_message'
|
||||||
|
LANGFUSE_LEGACY_IO_FALLBACK: bool = True
|
||||||
|
LANGFUSE_PUBLIC_KEY: str | None = None
|
||||||
|
LANGFUSE_SECRET_KEY: str | None = None
|
||||||
|
LANGFUSE_HOST: str = 'https://cloud.langfuse.com'
|
||||||
|
MODEL_PRICES_JSON: str | None = None
|
||||||
|
USD_BRL_RATE: str | None = None
|
||||||
|
ENABLE_OTEL: bool = False
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: str | None = None
|
||||||
|
OTEL_SERVICE_NAME: str = 'ai-agent-template'
|
||||||
|
# Dedicated NOC OpenTelemetry Logs channel. This is separate from trace/span OTel.
|
||||||
|
ENABLE_NOC_OTEL_LOGS: bool = False
|
||||||
|
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: str | None = None
|
||||||
|
OTEL_EXPORTER_OTLP_HOST_HEADER: str | None = None
|
||||||
|
|
||||||
|
ENABLE_ANALYTICS: bool = False
|
||||||
|
ANALYTICS_PROVIDERS: str = 'oci_streaming'
|
||||||
|
# Framework compatibility registry is loaded by default so legacy agents can
|
||||||
|
# adopt a newer framework without changing their observability/guardrail behavior.
|
||||||
|
OBSERVABILITY_DEFAULT_MAPPING_ENABLED: bool = True
|
||||||
|
OBSERVABILITY_DEFAULT_MAPPING_PATH: str | None = None
|
||||||
|
# Optional agent/deployment overlay applied on top of the framework defaults.
|
||||||
|
OBSERVABILITY_CODE_MAPPING_ENABLED: bool = False
|
||||||
|
OBSERVABILITY_CODE_MAPPING_PATH: str | None = None
|
||||||
|
GCP_PUBSUB_TOPIC_PATH: str | None = None
|
||||||
|
AGENT_PUBSUB_TOPIC: str | None = None
|
||||||
|
GCP_PROJECT_ID: str | None = None
|
||||||
|
GCP_PUBSUB_TOPIC: str | None = None
|
||||||
|
GCP_PUBSUB_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
# Payload shape is a transport concern. Domain-specific adapters must be selected by the embedding application.
|
||||||
|
PUBSUB_PAYLOAD_MODE: Literal['flat','legacy','envelope','wrapped'] = 'flat'
|
||||||
|
# Match the old Observer behavior: NOC.* goes to OTel Logs, not Pub/Sub.
|
||||||
|
PUBSUB_EXCLUDE_NOC: bool = True
|
||||||
|
|
||||||
|
# Automatic Pub/Sub sequence generation.
|
||||||
|
# auto: Redis if configured; otherwise MongoDB if configured; otherwise memory fallback.
|
||||||
|
# mongodb: atomic find_one_and_update/$inc.
|
||||||
|
PUBSUB_SEQUENCE_ENABLED: bool = True
|
||||||
|
PUBSUB_SEQUENCE_PROVIDER: Literal['auto','redis','mongodb','mongo','memory','none'] = 'auto'
|
||||||
|
PUBSUB_SEQUENCE_REDIS_URL: str | None = None
|
||||||
|
PUBSUB_SEQUENCE_MONGODB_URI: str | None = None
|
||||||
|
PUBSUB_SEQUENCE_MONGODB_DATABASE: str | None = None
|
||||||
|
PUBSUB_SEQUENCE_MONGODB_COLLECTION: str = 'observer_sequences'
|
||||||
|
PUBSUB_SEQUENCE_TTL_SECONDS: int = 86400
|
||||||
|
PUBSUB_SEQUENCE_MEMORY_FALLBACK: bool = True
|
||||||
|
PUBSUB_SEQUENCE_KEY_PREFIX: str = 'observer:sequence'
|
||||||
|
|
||||||
|
ANALYTICS_FAIL_SILENT: bool = True
|
||||||
|
|
||||||
|
ENABLE_OCI_STREAMING: bool = False
|
||||||
|
OCI_STREAM_ENDPOINT: str | None = None
|
||||||
|
OCI_STREAM_OCID: str | None = None
|
||||||
|
OCI_STREAM_PARTITION_KEY: str = 'agent-events'
|
||||||
|
|
||||||
|
ENABLE_INPUT_GUARDRAILS: bool = True
|
||||||
|
ENABLE_OUTPUT_GUARDRAILS: bool = True
|
||||||
|
ENABLE_PARALLEL_GUARDRAILS: bool = True
|
||||||
|
GUARDRAILS_FAIL_FAST: bool = True
|
||||||
|
# Optional LLM inference points. Defaults keep the current deterministic behavior.
|
||||||
|
ENABLE_JUDGES: bool = True
|
||||||
|
ENABLE_SUPERVISOR: bool = True
|
||||||
|
ENABLE_OUTPUT_SUPERVISOR: bool = True
|
||||||
|
OUTPUT_SUPERVISOR_MAX_RETRIES: int = 3
|
||||||
|
GUARDRAILS_CONFIG_PATH: str = './config/guardrails.yaml'
|
||||||
|
JUDGES_CONFIG_PATH: str = './config/judges.yaml'
|
||||||
|
PROMPT_POLICY_PATH: str = './config/prompt_policy.yaml'
|
||||||
|
AGENTS_CONFIG_PATH: str = './config/agents.yaml'
|
||||||
|
ROUTING_CONFIG_PATH: str = './config/routing.yaml'
|
||||||
|
ENABLE_LLM_ROUTER: bool = False
|
||||||
|
ROUTING_MODE: Literal['router','supervisor'] = 'router'
|
||||||
|
# Semantic route stickiness. Uses an LLM profile; no regex or language rules.
|
||||||
|
ENABLE_ROUTE_STICKINESS: bool = False
|
||||||
|
ROUTE_STICKINESS_LLM_PROFILE: str = 'route_continuity'
|
||||||
|
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD: float = 0.90
|
||||||
|
ROUTE_STICKINESS_HISTORY_TURNS: int = 2
|
||||||
|
ROUTE_STICKINESS_MAX_TOKENS: int = 80
|
||||||
|
HUMAN_HANDOFF_MESSAGE: str = 'Vou encaminhar seu atendimento para uma pessoa.'
|
||||||
|
END_SESSION_MESSAGE: str = 'Atendimento encerrado. Obrigado pelo contato.'
|
||||||
|
POST_FINALIZE_REPLAY_MESSAGE: str = (
|
||||||
|
'Por aqui finalizamos o tratamento da sua solicitação. '
|
||||||
|
'Aguarde um instante na linha.'
|
||||||
|
)
|
||||||
|
SESSION_ALREADY_ENDED_MESSAGE: str = 'Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.'
|
||||||
|
|
||||||
|
# MCP / Tooling
|
||||||
|
ENABLE_MCP_TOOLS: bool = True
|
||||||
|
ENABLE_MCP_CACHE: bool = True
|
||||||
|
MCP_CACHE_TTL_SECONDS: int = 300
|
||||||
|
MCP_SERVERS_CONFIG_PATH: str = './config/mcp_servers.yaml'
|
||||||
|
TOOLS_CONFIG_PATH: str = './config/tools.yaml'
|
||||||
|
# Opcional. Se ausente, permanecem válidas as políticas legadas de tools.yaml.
|
||||||
|
TOOL_POLICIES_PATH: str | None = './config/tool_policies.yaml'
|
||||||
|
ENABLE_TRANSACTIONAL_WORKFLOWS: bool = False
|
||||||
|
WORKFLOWS_PATH: str = './workflows'
|
||||||
|
IDENTITY_CONFIG_PATH: str = './config/identity.yaml'
|
||||||
|
MCP_PARAMETER_MAPPING_PATH: str = './config/mcp_parameter_mapping.yaml'
|
||||||
|
MCP_TOOL_TIMEOUT_SECONDS: int = 30
|
||||||
|
# When enabled, the framework routes tool calls to the dedicated MCP Gateway
|
||||||
|
# instead of calling individual MCP servers directly. The gateway then owns
|
||||||
|
# server selection, retry, cache and policy enforcement.
|
||||||
|
MCP_GATEWAY_ENABLED: bool = False
|
||||||
|
MCP_GATEWAY_URL: str = 'http://localhost:8300'
|
||||||
|
MCP_GATEWAY_TIMEOUT_SECONDS: int = 60
|
||||||
|
MCP_GATEWAY_TOKEN: str | None = None
|
||||||
|
MCP_GATEWAY_AGENT_ID: str = 'telecom_contas'
|
||||||
|
MCP_GATEWAY_TENANT_ID: str = 'default'
|
||||||
|
|
||||||
|
DEFAULT_CHANNEL: str = 'web'
|
||||||
|
# Agent Framework channel input mode.
|
||||||
|
# embedded = backend may use internal adapters to interpret simple/native payloads.
|
||||||
|
# external = backend accepts only GatewayRequest payloads already normalized by an external Channel Gateway.
|
||||||
|
FRAMEWORK_CHANNEL_INPUT_MODE: Literal['embedded','external'] = 'embedded'
|
||||||
|
# Legacy alias kept for compatibility with older .env files. Prefer FRAMEWORK_CHANNEL_INPUT_MODE.
|
||||||
|
CHANNEL_GATEWAY_MODE: str | None = None
|
||||||
|
ENABLE_VOICE_ADAPTER: bool = True
|
||||||
|
ENABLE_WHATSAPP_ADAPTER: bool = True
|
||||||
|
ENABLE_TEXT_ADAPTER: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# FIRST-ready runtime options
|
||||||
|
SQLITE_DB_PATH: str = './data/agent_framework.db'
|
||||||
|
ENABLE_SSE: bool = True
|
||||||
|
SSE_KEEPALIVE_SECONDS: float = 15.0
|
||||||
|
SSE_EVENT_REPLAY_LIMIT: int = 100
|
||||||
|
ENABLE_MESSAGE_IDEMPOTENCY: bool = True
|
||||||
|
ENABLE_LOCAL_CACHE: bool = True
|
||||||
|
CACHE_TTL_SECONDS: int = 300
|
||||||
|
CACHE_BACKEND_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'memory'
|
||||||
|
SSE_STORE_PROVIDER: Literal['sqlite','autonomous','oracle'] | None = None
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import json, base64, logging
|
||||||
|
logger=logging.getLogger('agent_framework.streaming')
|
||||||
|
|
||||||
|
class EventPublisher:
|
||||||
|
async def publish(self, event_type: str, payload: dict): ...
|
||||||
|
|
||||||
|
class NoopEventPublisher(EventPublisher):
|
||||||
|
async def publish(self, event_type, payload):
|
||||||
|
logger.info('event.noop %s %s', event_type, payload)
|
||||||
|
|
||||||
|
class OCIStreamingPublisher(EventPublisher):
|
||||||
|
def __init__(self, settings):
|
||||||
|
import oci
|
||||||
|
config = oci.config.from_file(settings.OCI_CONFIG_FILE, settings.OCI_PROFILE)
|
||||||
|
self.client = oci.streaming.StreamClient(config, service_endpoint=settings.OCI_STREAM_ENDPOINT)
|
||||||
|
self.stream_id = settings.OCI_STREAM_OCID
|
||||||
|
self.partition_key = settings.OCI_STREAM_PARTITION_KEY
|
||||||
|
async def publish(self, event_type, payload):
|
||||||
|
import oci
|
||||||
|
body = json.dumps({'type': event_type, 'payload': payload}, default=str).encode()
|
||||||
|
entry = oci.streaming.models.PutMessagesDetailsEntry(key=self.partition_key.encode(), value=body)
|
||||||
|
details = oci.streaming.models.PutMessagesDetails(messages=[entry])
|
||||||
|
self.client.put_messages(self.stream_id, details)
|
||||||
|
|
||||||
|
def create_event_publisher(settings):
|
||||||
|
if settings.ENABLE_OCI_STREAMING and settings.OCI_STREAM_ENDPOINT and settings.OCI_STREAM_OCID:
|
||||||
|
return OCIStreamingPublisher(settings)
|
||||||
|
return NoopEventPublisher()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user