Skip to content

fix: scope load_memory/search_memory to a reopened conversation's reset boundary - #75

Closed
mateusbellozupko wants to merge 44 commits into
evolution-foundation:mainfrom
mateusbellozupko:fix/evo-2241-memory-preload-epoch-scoping
Closed

mateusbellozupko wants to merge 44 commits into
evolution-foundation:mainfrom
mateusbellozupko:fix/evo-2241-memory-preload-epoch-scoping

Conversation

@mateusbellozupko

Copy link
Copy Markdown

Summary

  • Companion PR to fix: scope preload_memory/load_memory to a reopened conversation's reset boundary evo-ai-crm-community#402.
  • preload_memory (run automatically every turn) and the ADK-owned, model-invoked load_memory tool both call HttpMemoryService.load_memory/search_memory, keyed only by app_name/user_id - with no awareness of the CRM's ai_session_epoch reset boundary, so a reopened-after-resolved conversation still had pre-resolution memory re-injected even though its ADK session is correctly empty.
  • HttpMemoryService.load_memory/search_memory/add_event_to_memory/compress_memory now accept an optional min_timestamp, falling back to a task-local contextvars.ContextVar (set_memory_min_timestamp) when not passed explicitly - this is what scopes the on-demand load_memory tool without modifying that library-provided tool.
  • standard_runner.py / streaming_runner.py set that ContextVar once per turn from the request metadata's memoryMinTimestamp (sent by the CRM PR above), before any memory read or write happens in the turn.
  • add_event_to_memory/compress_memory also thread the floor through, so the write-side compression trigger can't fold pre-reset events into a fresh summary and have it slip past the read-side filter (the CRM PR's compression-scoping fix is the enforcement point; this lets it see the boundary).

Test plan

  • pytest tests/unit/services/test_memory_service_load.py tests/unit/services/test_memory_service_search.py tests/unit/services/test_memory_service_compress.py tests/unit/services/test_memory_service_add_event.py
  • pytest tests/unit/services/adk/runners/test_runner_memory_preload_wiring.py (incl. an asyncio.gather-based test proving the ContextVar does not leak between two concurrently running turns)
  • Full pytest tests/unit/ suite green (492 passed; one pre-existing, unrelated test_exception_handlers.py import error predates this branch and is excluded)

🤖 Generated with Claude Code

mateusbellozupko and others added 30 commits August 29, 2026 06:36
When the model called transfer_to_human with only `reason` (no explicit
team_id/assignee_id — the common case, since it only knows team NAMES from
the docstring, not their opaque IDs), the tool silently picked whichever
transfer rule happened to be first in the configured list, regardless of
the actual reason. Every escalation for an agent with multiple transfer
rules was routed to rule #1 — e.g. an "Imposto de Renda" request got sent
to "Dep. Contábil/Fiscal" (rule #1) instead of the dedicated IRPF team
(rule evolution-foundation#4), because the code never evaluated rule conditions at all (the
old comment literally said "In the future, this could be enhanced to
evaluate rule conditions").

Fix:
- Add a `rule_index` parameter and instruct the model (via the numbered
  rule list already in its docstring) to always pass it when transfer_rules
  are configured.
- If rule_index is omitted, fall back to matching `reason` against each
  rule's own `instructions` text by keyword overlap.
- Only if neither yields a match, fall back to the first configured rule
  (previous behavior), now logged as a warning so misroutes are visible.

Reproduced live: an "Imposto de Renda Pessoa Física" request was
transferred to "Dep. Contábil/Fiscal" instead of the dedicated team.
Conversation tagging (acts_as_taggable_on) accepts any free-form string
with no relation to the account's actual Label model (Settings > Labels).
The tool was letting the model tag conversations with invented label
titles that never existed in that catalog — invisible in Settings,
uncolored, and absent from any label-based filter.

`add` now fetches the account's label catalog and only applies titles
that already exist there (case-insensitive match); anything else is
skipped and reported back via `rejected` instead of silently tagging the
conversation with an ad-hoc string.
…rring

Every transfer rule's own instructions ask the agent to notify the
customer before transferring, but that depended entirely on the model
also producing reply text in the same turn as its tool calls — and when
the model decides to call tools, it frequently skips the customer-facing
text entirely (observed live: manage_conversation_labels + transfer_to_human
fired together with a null/empty assistant message, so the transfer
happened completely silently).

Add a message_to_customer parameter: when provided, the tool sends it as
a real outgoing message BEFORE performing the team/agent assignment, so
"notify then transfer" is one atomic tool action instead of two separate
things the model has to remember to do in sequence. Docstring now
instructs the model to always pass it when the matched rule's
instructions ask for a customer notice.
- transfer_to_human: an explicit but out-of-range rule_index now returns
  an error instead of silently falling through to keyword matching or the
  first rule (hid the model's contract violation before). Keyword
  fallback now scores rules by count of shared meaningful, non-stopword
  tokens and picks the best match instead of stopping at the first rule
  containing any word longer than 3 chars — a single generic word like
  "cliente" or "para" no longer misroutes to an unrelated rule.
- llm_agent_builder: the agent-level system prompt describing configured
  transfer rules said "the tool will automatically use the configured
  transfer rules, so you don't need to specify assignee_id or team_id" —
  directly contradicting the new rule_index requirement and explaining
  why models kept omitting it. Now numbers the rules (matching the tool's
  own docstring) and explicitly requires rule_index + message_to_customer.
- manage_conversation_labels: _fetch_catalog_labels now returns
  (fetch_succeeded, titles) so a legitimately empty label catalog is no
  longer indistinguishable from a failed fetch. Also fixed the add-result
  message to distinguish "already present" from "rejected" instead of
  claiming none of the requested labels existed when some were already
  attached.

Found via Sourcery review on the corresponding upstream PRs (evolution-foundation#55, evolution-foundation#56, evolution-foundation#57).
Builds and pushes this repo's own image to registry.bellosoft.dev on
every push to main, plus workflow_dispatch for manual builds. Replaces
depending on the superrepo's monolithic release.yml (kept as a manual
fallback) for routine releases, so a change to one service no longer
requires rebuilding all six.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merge upstream v1.1.0 (custom tool name sanitization)
docker-publish.yml logs into Docker Hub with DOCKERHUB_USERNAME/
DOCKERHUB_TOKEN, secrets this org never configured -- it's upstream's own
release pipeline for evoapicloud/*, carried over by the fork. It's been
failing on every push to main with "Username and password required" since
day one; harmless (bellosoft-release.yml is the real pipeline, pushing to
registry.bellosoft.dev), but it's pure noise on every CI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ublish-workflow

chore(ci): remove the inherited Docker Hub publish workflow
… knowledge search call

The knowledge preload block in standard_runner.py and streaming_runner.py
referenced settings.KNOWLEDGE_SERVICE_URL, a setting that has never existed
in this codebase, so the feature silently failed and was swallowed by a
broad except Exception. Extract the logic into a standalone, testable
knowledge_preload module that calls the real CRM endpoint
(POST {EVO_AI_CRM_URL}/api/v1/internal/knowledge/search, X-Service-Token
auth) built in evo-ai-crm-community, removing ~90 lines of duplicated
inline logic from each runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bled + error paths

The prior test only asserted "knowledge/search" appeared in the call args, a
substring shared by the old broken settings.KNOWLEDGE_SERVICE_URL reference,
so it would not have caught a regression back to the broken path. Strengthen
it to assert the exact CRM URL, X-Service-Token header, and JSON payload
fields, and add coverage for the disabled (load_knowledge/preload_knowledge
False) path and for HTTP failures propagating out of preload_knowledge so the
runner's own try/except can log and continue instead of crashing the turn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… instead of a dead URL

The tool previously hand-rolled a raw httpx GET against settings.KNOWLEDGE_SERVICE_URL /
KNOWLEDGE_SERVICE_API_TOKEN, neither of which exist in this codebase's settings module,
so memory preload always failed. It now delegates to the already-correct
memory_service.search_memory(...) (HttpMemoryService), matching how the rest of the
memory pipeline (add_event_to_memory, compress_memory) is wired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e instead of a dead URL

Task 6: replace the hand-rolled httpx call against non-existent
KNOWLEDGE_SERVICE_URL/KNOWLEDGE_SERVICE_API_TOKEN settings with a call to
HttpMemoryService.compress_memory (added in Task 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rking memory_service

The inline memory-preload block in both runners referenced
settings.KNOWLEDGE_SERVICE_URL, which does not exist in this codebase's
settings module, so preload silently no-opped (swallowed by a broad
except). Extract the logic into a shared, tested memory_preload.py that
calls the memory_service singleton's search_memory() instead, mirroring
the fix already applied to preload_memory_tool.py and
compress_memory_tool.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uery

Preload was calling search_memory(query="") -> POST /memory/search, which
returns medium-term summaries mixed with raw short-term events, polluting the
preloaded context and defeating compression. GET /memory/load exists precisely
for this case and returns summaries only, but nothing called it.

- Add HttpMemoryService.load_memory(). http_client.do_get_json takes no
  `params` argument, so the query string is urlencoded into the URL.
- Point memory_preload.preload_memory() and preload_memory_tool's
  preload_memory_with_client() at load_memory, consuming plain dicts
  (mem["content"] / mem.get("timestamp")) instead of pydantic MemoryEntry.
- Drop the unused `session` parameter from preload_memory().
- Replace memory_preload.py's non-standard box-drawing header with a plain
  module docstring (it carried no @author/license block and its border was
  misaligned).
- Fold the duplicated x-memory-base-config-id header block into
  _get_headers(memory_base_config_id), removing five copies and two dead
  isinstance branches whose arms were identical.

Tests run:
  tests/unit/services/test_memory_service_load.py
  tests/unit/services/adk/runners/test_memory_preload.py
  tests/unit/services/adk/tools/test_preload_memory_tool.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both runners had the preload_memory() call and its append_event inside the
same outer try whose only handler logs at debug, so a memory preload failure
was near-invisible in production AND skipped the knowledge preload that runs
after it. Restore the nested try/except with a warning-level log, as existed
before the preload block was extracted.

Also adds the missing runner-level wiring tests (no test previously covered
the runner -> preload_memory -> append_event integration point) and two minor
compress_memory_tool cleanups:

- Remove the unused `import uuid`.
- Thread the per-agent memory_medium_term_compression_interval through
  create_compress_memory_tool() into memory_service.compress_memory(), which
  previously always passed None. The value is available at the only call site
  (llm_agent_builder), so no invented parameter is involved.
- Correct the tool docstrings to list only the statuses the code can produce
  ("success" | "error"). The internal endpoint reports "not enough events" and
  a real failure with shape-identical dicts, so a distinct "not_ready" status
  cannot be derived cleanly; the docstring now says "message" carries that
  distinction instead of promising unreachable statuses.

Tests run:
  tests/unit/services/adk/runners/test_runner_memory_preload_wiring.py
  tests/unit/services/adk/tools/test_compress_memory_tool.py
  plus the full tests/unit/ suite as a regression check

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… independently-landed knowledge_preload.py

Another concurrent branch already extracted a real knowledge_preload.py
module and fixed its own KNOWLEDGE_SERVICE_URL bug (commit f441570) between
when this branch diverged and now. That rewrite dropped the specific
"Preloading knowledge for agent X" log line the wiring test asserted on.
The underlying behavior this test verifies (a memory-preload failure does
not skip knowledge preload) is unchanged and still holds — only the
assertion needed to catch up to the new, real implementation.
…emory namespace

HttpMemoryService.__init__ defaulted self.base_url to settings.CORE_SERVICE_URL,
which every deployment config points at the Go core service (no /memory/* routes)
instead of the Rails CRM. Every memory HTTP call (event, search, load, compress)
built off that base_url 404s in production as a result.

Default base_url is now settings.EVO_AI_CRM_URL.rstrip("/") + "/api/v1/internal",
matching the pattern already used by knowledge_preload.preload_knowledge for the
sibling /api/v1/internal/knowledge/search route, and matching the real
Api::V1::Internal::MemoryController routes on the CRM.

Explicit base_url arguments (used throughout the existing test suite) are
unaffected - only the default changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ring selection

Keyword matching and the first-valid-rule fallback both need a rule with
a usable userId/teamId for its transferTo, but the keyword-scoring loop
selected purely on instructions-text overlap without that check. A
misconfigured rule (e.g. transferTo: team with no teamId) could win the
keyword match on its instructions alone, then leave both
effective_assignee_id and effective_team_id unset — a hard "assignee_id
or team_id is required" error instead of falling through to the next,
actually-usable rule.

Extracted the existing valid-target check (already used by the
first-valid-rule fallback) into _rule_has_valid_target and applied it to
the keyword-scoring loop too. An explicit rule_index still errors on its
own malformed rule rather than silently substituting another — that's
the model's own contract failure, not something to paper over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ure too

The customer notice and the assignment are two separate, already-committed
POSTs. message_to_customer_sent/message_to_customer_error were only
included in the success response — if the notice succeeded but the
subsequent assignment POST failed, the error response gave no indication
the customer had already been told a transfer was imminent, so a caller
or model retry had no way to know not to resend the same notice.

Computed once right after the notice attempt so every return path
(success, assignment error, and the outer unexpected-error handler)
reports the same outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Switch GitHub-hosted ubuntu-latest runners to the self-hosted bellosoft
runner ([self-hosted, X64, Linux, bellosoft]), matching the label already used
by the working Release (Bellosoft) job. GitHub Actions billing on the org has
been blocking every ubuntu-latest job (guards, tests, migration checks) while
only jobs already on the self-hosted runner kept running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… just add_to_pipeline (evolution-foundation#3)

The pipeline_manipulation tool accepted a custom_fields parameter
(src/services/adk/tools/evo_crm/pipeline_manipulation.py:118), but it was
only wired into _add_to_pipeline (card creation, lines 370-451). The
_move_to_stage action (lines 454-543) — the one used for the normal case of
an ongoing conversation, per the tool's own docstring — never received or
applied custom_fields at all, so there was no code path to persist a
custom field on an existing card. This forced the agent to fall back to
update_contact, writing the attribute onto the Contact instead of the
pipeline_item (card), which breaks the intended data model: a contact can
represent more than one deal over time, so per-deal attributes belong on
the card, not the contact.

Fix: _move_to_stage now accepts custom_fields and, when provided, resolves
the real pipeline_item_id from conversation_id (same lookup pattern
_create_task already used against GET /pipelines/{id}/pipeline_items,
lines 584-598) and calls
PATCH /pipelines/{pipeline_id}/pipeline_items/{pipeline_item_id}/update_custom_fields
with {"custom_fields": {...}}, in addition to the existing move_to_stage
PATCH. That resolution step is required because update_custom_fields does
not accept conversation_id/display_id in place of the pipeline_item's own
id. Extracted the shared lookup into _find_pipeline_item_id() and reused
it in _create_task too. When custom_fields is absent/None/empty,
_move_to_stage's behavior is unchanged.

_add_to_pipeline is untouched — creation already worked correctly.

Updated tests/unit/test_pipeline_tool_context_ids.py's _move_to_stage
mock to accept the new custom_fields kwarg.

Not tested against a live CRM instance (no live environment available in
this sandbox); tests/unit passes in full (418 passed) against the
existing suite.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…talog

The "not added" branch (label already present or rejected by the catalog
check) returned silently with no logger call, unlike the success and
exception paths — making it impossible to tell from logs why a requested
add produced no visible progress. Added a logger.info call there.

Also cache the label catalog fetch (GET /labels) for 30s per tool instance,
since every single `add` call re-fetched the full catalog even when it had
just been fetched moments earlier by a previous add in the same agent run.

Found while investigating a 2026-09-22 incident (CRM-236): a lead_qualifier
turn issued 4 manage_conversation_labels calls ~8-10s apart (2 catalog
fetches each on the failed attempts) before one add finally succeeded,
pushing the whole agent run past the AI_CALL_TIMEOUT_SECONDS ceiling and
triggering a client-disconnect cancellation mid-turn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The use_emojis config toggle only ever injected a prompt instruction
when set to true (encouraging emoji use); when false it silently added
nothing, leaving emoji suppression entirely up to the agent's own
free-text instruction or the model's default behavior. Operators
turning the toggle off saw no effect. Now the false/unset path
explicitly instructs the model not to use emojis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpenRouter's default provider routing balances price and speed, which
can pick a cheaper-but-slower endpoint even when a faster one is
available for the same model. Added `extra_body={"provider": {"sort":
"latency"}}` to the LiteLLM kwargs for openrouter-routed agents —
LiteLLM's documented mechanism for forwarding OpenRouter-specific
request fields (see litellm/main.py's OpenrouterConfig handling).

Verified against OpenRouter's official TypeScript SDK
(OpenRouterTeam/ai-sdk-provider) that `provider.sort` is a real field
on the standard /chat/completions schema — as opposed to
`preferred_max_latency`, which only exists on the separate Decisions
API and would have had no effect here.

Found while investigating a 2026-09-22 incident where a single GLM
generation on GMICloud (normally our fastest/most reliable provider)
took ~104s, exceeding the agent-run timeout mid-turn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /pipelines/{id}/pipeline_items wraps its list under "data"
(`{"success": true, "data": [...], "meta": {...}}`), not "payload" —
that key belongs to a different endpoint shape (conversation labels).
Reading items_response.get("payload", []) silently returned an empty
list every time, so _find_pipeline_item_id always returned None even
when the pipeline item existed, breaking the custom_fields-on-move
fix from evolution-foundation#3 and create_task's item lookup end to end since they were
introduced — the existing test suite mocked _move_to_stage itself and
never exercised this function's real response parsing.

Confirmed live 2026-09-22: a card had already moved stage successfully
(the PATCH before this lookup) while the custom_fields patch failed
with "could not find the pipeline item to update custom fields on".

Added tests/unit/test_find_pipeline_item_id.py exercising the real
response shape end to end (would have caught this before merge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mateusbellozupko and others added 14 commits September 22, 2026 08:26
GLM (via OpenRouter) called manage_conversation_labels with
labels='["qualificado_ntba", "reuniao_confirmada", "etapa_concluida"]'
— a single string, not a real JSON array. _coerce_input_list treated
the whole bracketed string as one literal label, which never matched
the catalog and was silently rejected. This surfaced to the model (and
then the user, via its own private note) as "these labels don't
exist" — a hallucinated explanation for what was actually a malformed
tool argument, never a real catalog lookup.

_coerce_input_list now detects a string shaped like a JSON array and
parses it back into a real list before doing anything else. Falls
back to the previous literal-string behavior if the bracketed string
isn't valid JSON, so a genuinely odd label name doesn't silently
vanish either.

Found while investigating a 2026-09-22 conversation where qualified
labels were never applied to the conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(CRM-572)

TenantContextMiddleware treated every exception from current_context_id as a
resolution failure and ran the request unbound, so a refusal the extension
point decided on (a 403) surfaced downstream as a 500 or as an empty read.
The class moves to src/middleware/tenant_context.py so it can be tested
without importing the app.
Trivial lint cleanup on the just-cherry-picked CRM-572 fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
O processor entrava em crash-loop no boot com
`DuplicateTableError: relation "evo_agent_processor_execution_metrics"
already exists` durante o `alembic upgrade head` da chain community — antes
de chegar no create_all e na chain do licensing.

Causa: a unica migration (down_revision=None) fazia um CREATE TABLE cru
sem guard. Quando a tabela ja existe fisicamente (criada por um run
anterior, ou pelo Base.metadata.create_all de uma imagem mais antiga) mas
o alembic_version desta alembic esta vazio, o upgrade re-roda do zero e
colide.

Fix: guard `to_regclass` em upgrade (pula o CREATE se a tabela existe; a
revisao ainda e' stampada, reconciliando o drift) e simetria no downgrade.

Prova RED/GREEN contra Postgres real (banco driftado: tabela presente +
alembic_version vazio): sem o fix, `alembic upgrade head` estoura
DuplicateTable; com o fix, roda como no-op e registra a versao. Unit test
novo (roda na lane unit da CI, sem DB) cobre o guard via mock do bind —
falha sem o fix.

Debito conhecido (follow-up, fora deste escopo): divergencia de nomes — o
model ExecutionMetrics usa `evo_ai_agent_processor_execution_metrics`
enquanto esta migration e o usage_reporter usam `evo_agent_processor_
execution_metrics` (rename feito so no model). Reconciliar exige decisao
sobre dados legados.
Review follow-ups on the guard:

- The skip was silent, so an operator could not tell a stamped revision
  from an applied one. Log it on alembic's own logger, next to the
  "Running upgrade" lines in the boot log.
- Drop the unreferenced module-level sa.Table block: it re-hardcoded the
  literal the new TABLE constant exists to hold.
- Trim the upgrade and test docstrings to what the code cannot say.
- Cover the fourth branch: downgrade drops when the table is present.
- usage_reporter's docstring named evo_agent_processor_execution_metrics,
  but execution metrics are persisted through the ExecutionMetrics model
  into evo_ai_agent_processor_execution_metrics; the file holds no SQL.
…volution-foundation#66)

Old custom tools stored each body param as a plain string instead of the
{type, required, description} schema. Building the tool docstring called
.get on that string and raised AttributeError, breaking tool creation for
both CustomToolBuilder and ToolBuilder.

Add _normalize_body_param to coerce a string body param into a required
string schema whose description is the string, and route both docstring
loops through it. A string-shaped body param no longer crashes the build
and its param is still exposed to the LLM.

Claude-Session: https://claude.ai/code/session_018zYu3wd6KQkuG76B9qTXSA
The ADK builds the tool declaration the model sees from the function
signature, never from the docstring. http_tool(**kwargs) therefore reached
the LLM as a tool taking no arguments: it called with none, body_data came
out empty and the endpoint answered 400 body cannot be empty. That is the
symptom CRM-527 reports, and it survived the docstring fix.

Synthesize the signature from body_params before handing the function to
FunctionTool, in both builders. A required param is declared as its type, an
optional one as Optional[T] with a default — a bare None default makes the
ADK drop the required list and every param turns optional. A name that is not
a Python identifier stays docstring-only instead of breaking the build.

Also drops the underscore from normalize_body_param, which tool_builder
already imports across module boundaries.

Claude-Session: https://claude.ai/code/session_019CSZZDzLqvHC19nybzcHtV
get_agent/get_agents_by_account sanitizavam o nome do agente com um guard inline
baseado em str.isalnum() -- que e True para acentuados ("cafe".isalnum() -> True,
com acento). O acento passava, e quando o agente e anexado como tool de outro
(llm_agent_builder -> AgentTool) o nome vira tools[].function.name; o provedor
exige ^[a-zA-Z0-9_-]+$ -> 400 -> 500 opaco no chat (mesma familia do CRM-499).

Reusa o helper que o CRM-499 deixou no mesmo repo (src/utils/tool_naming.py:
sanitize_tool_name -- coage p/ ^[a-zA-Z0-9_-]+, cap 64, passthrough p/ nomes ja
validos, fallback "tool"). Preserva o db.commit() so quando o nome muda.

Exposicao: cadastro pela UI passa por sanitizeAgentName no front (e o a2a tem o
sanitizeName em Go); o furo e o agente criado por API/seed/import, onde o
fallback do backend era a unica barreira -- e ela deixava o acento passar.

QA:
- prova standalone: o sanitizador velho produz 'Cafe_Acao' com acento
  (invalido); o helper produz 'Caf_A_o' (valido). "cafe(acento)".isalnum() True.
- teste novo tests/unit/test_agent_name_sanitization.py (roda no ci.yml =
  pytest tests/unit, py3.11): nome acentuado via get_agent/get_agents_by_account
  -> function.name valido + persiste; nome ja valido fica intacto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpWaS9MUbLhMK8QGy3GKML
…m (CRM-501)

Sourcery apontou 2 furos reais:
1. nome vazio/None escapava (guard `if agent.name`) e continuava invalido p/
   tools[].function.name. Agora chama sanitize_tool_name incondicional -- o
   fallback "tool" cobre vazio/None. Persiste so quando muda.
2. o passthrough do helper usava `^...$`, e em Python `$` casa ANTES de um `\n`
   final -> "valid\n" passava direto (ainda invalido). Troca p/ `\A...\Z`
   (ancora absoluta) no _VALID_NAME. Corrige tambem o CRM-499 (custom tools),
   que compartilha o helper.

Testes: os asserts passam a usar .fullmatch (.match tinha o mesmo furo do `$`);
+2 casos -- nome vazio -> "tool" (persiste); "valid\n" -> "valid". Prova
standalone reconfere todos os casos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpWaS9MUbLhMK8QGy3GKML
…tion.name (CRM-501)

sanitize_tool_name keeps "-", because the providers accept it in
tools[].function.name. An agent name goes through the ADK first, and
google-adk's BaseAgent validates name.isidentifier() -- "-" fails it. So
routing agent names through the tool helper traded the accent 400 for an
ADK ValidationError on names that used to work: the old inline sanitizer
turned "-" into "_", the helper does not.

It is the everyday path, not an import edge: the UI's own sanitizeAgentName
keeps "-" on purpose, so "Suporte - N1" is stored as "suporte_-_n1". Proved
against google-adk 1.19.0:

  'suporte_-_n1'  old-> 'suporte___n1'  OK  | new-> 'suporte_-_n1'  REJECTED
  'brave-search'  old-> 'brave_search'  OK  | new-> 'brave-search'  REJECTED

Every agent type is affected, not just llm: LlmAgent, A2ACustomAgent,
WorkflowAgent and TaskAgent all extend BaseAgent.

Adds sanitize_agent_name next to sanitize_tool_name -- the intersection of
the two contracts: the provider alphabet plus a leading-digit prefix and
"-" folded to "_", so the result is also an identifier. A name the ADK
already accepts still comes back untouched. Kept in the same module (a
rename would conflict with the open PRs on custom_tools.py/tool_builder.py);
its docstring now describes both consumers instead of claiming custom HTTP
tools are the only one.

Also logs the rename. get_agent rewrites the name in the database, and the
64-char cap made that reach names that were previously left alone, so the
log is the only trace the user has of a rename they cannot undo.

Tests: the assertions now build a real LlmAgent instead of restating our own
regex -- that restatement is what let the hyphen through. New cases for the
hyphen, the leading digit and the over-64 truncation, plus a standalone
contract test for sanitize_agent_name.
…d via a task-local ContextVar

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dary

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
add_event_to_memory and compress_memory now fall back to the same
task-scoped min_timestamp as load_memory/search_memory, closing the gap
where the write-side compression trigger could fold pre-reset events into
a fresh summary that then passed the read-side filter under its own (now)
created_at. The Rails-side compression scoping (already merged) is the
enforcement point; this lets it see the same boundary via the request the
processor already sends.

Found in code review of the EVO-2241 fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an asyncio.gather-based isolation test against the real
ContextVar-reading load_memory (not a mock of preload_memory), so the
contextvars.ContextVar design choice - the property that makes it safe to
scope the ADK-owned load_memory tool via a shared HttpMemoryService
singleton - is actually verified rather than only argued for in a comment.

Found in code review of the EVO-2241 fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @mateusbellozupko, your pull request is larger than the review limit of 150,000 diff characters

@mateusbellozupko

Copy link
Copy Markdown
Author

Closing: this fix depends on the ai_session_epoch mechanism (EVO-2241), which only exists in the Bellosoft-Limited fork and not in evolution-foundation. Re-targeting this fix at Bellosoft-Limited/evo-ai-processor-community main instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants