new feature: External guardrails/judges

This commit is contained in:
2026-08-24 11:14:10 -03:00
parent 05373deff2
commit fd37138c4f
384 changed files with 40593 additions and 3621 deletions

View File

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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=

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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"]

View File

@@ -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)

View File

@@ -0,0 +1 @@
"""Exemplos de uso do template backend enterprise."""

View File

@@ -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",
)

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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",
)

View File

@@ -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})

View File

@@ -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})

View File

@@ -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()

View File

@@ -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")),
)

View File

@@ -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})

View File

@@ -0,0 +1,3 @@
from .tool_renderers import register_tool_renderers
__all__ = ["register_tool_renderers"]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -0,0 +1,7 @@
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6

View File

@@ -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.

View File

@@ -0,0 +1,8 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true

View File

@@ -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

View File

@@ -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.

View File

@@ -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: []

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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
```

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,3 @@
compileall app: OK
Arquivos de exemplos IC/NOC/GRL adicionados.
Agentes preservam implementação original comentada.

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View 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.

View File

@@ -0,0 +1,4 @@
__all__ = ['settings']
from .config.settings import settings
from .idempotency import IdempotencyStore, InMemoryIdempotencyStore, create_idempotency_store

View File

@@ -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",
]

View File

@@ -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])

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()))

View File

@@ -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}

View File

@@ -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

View File

@@ -0,0 +1 @@
from .usage_repository import UsageRecord, UsageRepository, SQLiteUsageRepository, OracleUsageRepository, create_usage_repository

View File

@@ -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)

View 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))

View File

@@ -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}

View File

@@ -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: ...

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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

View File

@@ -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()

View File

@@ -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