feat: emit _scopes in CNF (all_of/any_of) format
loading diff…
Generated tools currently write _scopes as a flat list[str], but mercury's runtime (mercury/tools/_base/action.py::validate_scopes) and every real tool in mercury/apps/gmail/actions/ and mercury/apps/googlecalendar/actions/ use the CNF dict shape {"all_of": [{"any_of": [...]}, ...]}. This PR makes the builder + scope-mapper pipeline emit the same CNF shape so new tools match existing conventions and express real permission semantics — alternatives for one permission group into a single any_of; distinct required permissions become separate all_of clauses (e.g. People API searchContacts needs contacts OR contacts.readonly AND contacts.other.readonly).
cortex/common/templates.py — ACTION_TEMPLATE placeholder switched to _scopes = {"all_of": [{"any_of": [...]}]}.cortex/agents/action_builder/prompt.py — added explicit _scopes CNF-skeleton instruction under <action_description> so the builder never emits a flat list.cortex/agents/scope_mapper/prompt.py — new "SCOPE SHAPE — CNF" section with single-clause (common) vs multi-clause (rare, with search_people example) guidance; updated output schema and Phase 5 persistence example.cortex/agents/scope_mapper/models.py — new CnfScopes / AnyOfClause models; ScopeSearcherResponse.scopes and MapActionScopesResponse.scopes_by_action re-typed. model_validator(mode="before") coerces legacy List[str] and bare-string all_of entries into CNF so the eval dataset and mercury's permissive runtime both load cleanly.cortex/agents/scope_mapper/agent.py — emptiness via cnf.is_empty(), counting via leaf_scopes(), dict typed Dict[str, CnfScopes].cortex/agents/scope_mapper/eval.py — Jaccard scoring on flattened leaves; fallback constructs CnfScopes(); detailed output uses model_dump().cortex/workflows/scope_mapper.py — serializes CNF via model_dump() before writing; PR body renders (a OR b) AND (c) per action.cortex/workflows/build_action.py, cortex/workflows/create_app.py — consume CNF from scopes_by_action, dump to dict before passing to metadata writer.cortex/common/action_metadata.py — scopes param widened to Union[List[str], Dict[str, Any]]; repr() already emits valid Python for the dict form.verify_scopes tool intentionally kept on flat List[str] — leaf comparison is correct for documentation verification; the searcher flattens leaves before the call and regroups in its final output.make fmt && make chk pass (verified locally)uv run pytest cortex/tests/test_common/test_action_metadata.py -v — 9/9 pass (verified locally)CnfScopes coercion:
from cortex.agents.scope_mapper.models import CnfScopes
CnfScopes.model_validate(["scope1", "scope2"]).model_dump()
# -> {"all_of": [{"any_of": ["scope1", "scope2"]}]}
CnfScopes.model_validate({"all_of": ["bare_scope", {"any_of": ["a", "b"]}]}).leaf_scopes()
# -> ["bare_scope", "a", "b"]
_scopes is {"all_of": [{"any_of": [...]}]}.CnfScopes validator still accepts List[str] and mixed all_of with bare strings — the existing eval_dataset.json (flat-list scopes) loads unchanged._scopes rendered via repr() on the dumped dict: _scopes = {'all_of': [{'any_of': ['scope1', 'scope2']}]} — valid Python literal that mercury's validator accepts._scopes in already-shipped apps (mercury accepts both, so it's cosmetic until a codebase-wide sweep is done).🤖 Generated with Claude Code