Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ Primary index description [Search index for products_{{event_id}}.]: Product cat

? Add a replica index: <done — no more replicas>

Set up searchControls to limit hits or restrict attributes? [N]: y
Cap hitsPerPage? Enter max (or leave blank to skip): 10
Cap page? Enter max (or leave blank to skip):
Restrict attributesToRetrieve? Enter comma-separated list (or leave blank to skip): objectID, name, price
Enable facets? Enter comma-separated list (or leave blank to skip):
Restrict responseFields? Enter comma-separated list (or leave blank to skip):

✓ agent-config.json
✓ PROMPT.md

Expand Down Expand Up @@ -126,12 +133,15 @@ and the instructions file in a single pass — missing vars are reported togethe
}
```

`searchControls` constrains what the LLM can do at query time. It is applied to every index (primary + replicas). Common fields:
`searchControls` constrains what the LLM can do at query time. It is applied to every index (primary + replicas). Supported fields:

| Field | What it does |
|---|---|
| `hitsPerPage` | Limit result count. Set `constraint.max` to cap it regardless of what the LLM requests. |
| `attributesToRetrieve` | Restrict which attributes are returned in each hit. Useful for limiting the payload to only the fields the LLM actually needs. |
| `hitsPerPage` | Limit result count. Set `constraint.max` to cap it. |
| `page` | Limit pagination depth. Set `constraint.max` to cap it. |
| `attributesToRetrieve` | Restrict which attributes are returned in each hit. Useful for limiting the LLM payload to only the fields it needs. |
| `facets` | Control which facet attributes are returned in the response. |
| `responseFields` | Restrict which top-level response fields are returned. |

Set `exposed: true` to let the LLM vary the value within the constraint; `exposed: false` to fix it.

Expand Down
91 changes: 79 additions & 12 deletions src/algolia_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,45 @@ def _diff(current: dict, new_payload: dict) -> list[str]:
f"({len(curr_instr.splitlines())} lines → {len(new_instr.splitlines())} lines)"
)

curr_sc_map = {
i["index"]: i.get("searchControls")
for t in current.get("tools", [])
for i in t.get("indices", [])
}
new_sc_map = {
i["index"]: i.get("searchControls")
# Only compare keys present in the new payload — the API expands searchControls
# with default fields (query, page, responseFields, etc.) that we never sent,
# which would otherwise cause a spurious diff on every dry-run.
# Skip comparison entirely when no index in the new payload carries a searchControls key.
new_has_sc = any(
"searchControls" in i
for t in new_payload.get("tools", [])
for i in t.get("indices", [])
}
if curr_sc_map != new_sc_map:
curr_repr = json.dumps(next(iter(curr_sc_map.values()), None))
new_repr = json.dumps(next(iter(new_sc_map.values()), None))
lines.append(f" searchControls: {curr_repr} → {new_repr}")
)
if new_has_sc:
new_sc_keys = {
k
for t in new_payload.get("tools", [])
for i in t.get("indices", [])
for k in (i.get("searchControls") or {})
}
if new_sc_keys:
# Filter current to only the keys we're sending, ignoring API-expanded defaults.
curr_sc_map = {
i["index"]: {k: v for k, v in (i.get("searchControls") or {}).items() if k in new_sc_keys}
for t in current.get("tools", [])
for i in t.get("indices", [])
}
else:
# New payload sends searchControls: {} — compare unfiltered so clearing is detected.
curr_sc_map = {
i["index"]: (i.get("searchControls") or {})
for t in current.get("tools", [])
for i in t.get("indices", [])
}
new_sc_map = {
i["index"]: (i.get("searchControls") or {})
for t in new_payload.get("tools", [])
for i in t.get("indices", [])
}
if curr_sc_map != new_sc_map:
curr_repr = json.dumps(next(iter(curr_sc_map.values()), None))
new_repr = json.dumps(next(iter(new_sc_map.values()), None))
lines.append(f" searchControls: {curr_repr} → {new_repr}")

curr_idx = {
i["index"]: i.get("description", "")
Expand Down Expand Up @@ -632,9 +657,49 @@ def cmd_init(args: argparse.Namespace):
selected_replica_indices.add(replica_index)
replica_desc = _ask(" Replica description", f"Replica index of {index_description}")
replicas.append({"index": replica_index, "description": replica_desc})

search_controls: dict = {}
print()
if _ask("Set up searchControls to limit hits or restrict attributes?", "N").lower() == "y":
while True:
max_hits = _ask(" Cap hitsPerPage? Enter max (or leave blank to skip)")
if not max_hits:
break
try:
n = int(max_hits)
search_controls["hitsPerPage"] = {"exposed": False, "default": n, "constraint": {"max": n}}
break
except ValueError:
print(" Please enter a whole number.")
while True:
max_page = _ask(" Cap page? Enter max (or leave blank to skip)")
if not max_page:
break
try:
n = int(max_page)
search_controls["page"] = {"exposed": False, "default": 0, "constraint": {"max": n}}
break
except ValueError:
print(" Please enter a whole number.")
attrs_raw = _ask(" Restrict attributesToRetrieve? Enter comma-separated list (or leave blank to skip)")
if attrs_raw:
attrs = [a.strip() for a in attrs_raw.split(",") if a.strip()]
if attrs:
search_controls["attributesToRetrieve"] = {"exposed": False, "default": attrs}
facets_raw = _ask(" Enable facets? Enter comma-separated list (or leave blank to skip)")
if facets_raw:
facets_list = [f.strip() for f in facets_raw.split(",") if f.strip()]
if facets_list:
search_controls["facets"] = {"exposed": False, "default": facets_list}
fields_raw = _ask(" Restrict responseFields? Enter comma-separated list (or leave blank to skip)")
if fields_raw:
fields_list = [f.strip() for f in fields_raw.split(",") if f.strip()]
if fields_list:
search_controls["responseFields"] = {"exposed": False, "default": fields_list}
else:
index_description = None
replicas = []
search_controls = {}

config = {
"_note": "Generated by algolia-agent init. Use --var key=value to supply template variables.",
Expand All @@ -648,6 +713,8 @@ def cmd_init(args: argparse.Namespace):
config["index_description"] = index_description
if replicas:
config["replicas"] = replicas
if search_controls:
config["searchControls"] = search_controls

out_dir.mkdir(parents=True, exist_ok=True)

Expand Down
126 changes: 116 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,8 @@ def test_init_writes_config_and_prompt(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
# _select: provider, index, replica(done). input: model (text fallback), name, instructions, description
inputs = iter(["gemini-2.5-flash", "My Agent", "PROMPT.md", "Main product catalog."])
# _select: provider, index, replica(done). input: model (text fallback), name, instructions, description, searchControls(skip)
inputs = iter(["gemini-2.5-flash", "My Agent", "PROMPT.md", "Main product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
with _mock_init_client(providers):
with _mock_select(["hackathon-gemini", "products", "<done — no more replicas>"]):
Expand All @@ -310,6 +310,7 @@ def test_init_with_replicas(tmp_path, monkeypatch):
"gemini-2.5-flash", "My Agent", "PROMPT.md",
"Product catalog.",
"products_{{event_id}}_price_asc", "Sorted by price asc.",
"N",
])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
with _mock_init_client(providers):
Expand All @@ -327,8 +328,8 @@ def test_init_prompts_for_missing_credentials(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
# input: app_id, save_to_env, model (text), name, instructions, description
inputs = iter(["MYAPPID", "n", "gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog."])
# input: app_id, save_to_env, model (text), name, instructions, description, searchControls(skip)
inputs = iter(["MYAPPID", "n", "gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
monkeypatch.delenv("ALGOLIA_APP_ID", raising=False)
monkeypatch.delenv("ALGOLIA_API_KEY", raising=False)
Expand All @@ -354,7 +355,7 @@ def test_init_saves_credentials_to_dotenv(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
inputs = iter(["MYAPPID", "Y", "gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog."])
inputs = iter(["MYAPPID", "Y", "gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
monkeypatch.delenv("ALGOLIA_APP_ID", raising=False)
monkeypatch.delenv("ALGOLIA_API_KEY", raising=False)
Expand All @@ -380,7 +381,7 @@ def test_init_model_selector(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "provider-uuid", "name": "hackathon-gemini"}]
inputs = iter(["My Agent", "PROMPT.md", "Main product catalog."])
inputs = iter(["My Agent", "PROMPT.md", "Main product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
mock_client = MagicMock()
mock_client.list_providers.return_value = providers
Expand All @@ -401,7 +402,7 @@ def test_init_index_selector_existing(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "provider-uuid", "name": "hackathon-gemini"}]
inputs = iter(["My Agent", "PROMPT.md", "Product catalog."])
inputs = iter(["My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
mock_client = MagicMock()
mock_client.list_providers.return_value = providers
Expand All @@ -421,7 +422,7 @@ def test_init_index_selector_custom(tmp_path, monkeypatch):
from algolia_agent.cli import cmd_init

providers = [{"id": "provider-uuid", "name": "hackathon-gemini"}]
inputs = iter(["My Agent", "PROMPT.md", "Product catalog."])
inputs = iter(["My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
mock_client = MagicMock()
mock_client.list_providers.return_value = providers
Expand Down Expand Up @@ -485,7 +486,7 @@ def test_init_model_selector_fallback_on_error(tmp_path, monkeypatch):
from algolia_agent.client import AgentAPIError

providers = [{"id": "provider-uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
inputs = iter(["gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog."])
inputs = iter(["gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
mock_client = MagicMock()
mock_client.list_providers.return_value = providers
Expand All @@ -507,6 +508,51 @@ def test_init_non_tty_errors(monkeypatch):
cmd_init(MagicMock(output_dir="."))


def test_init_with_search_controls(tmp_path, monkeypatch):
"""Full searchControls walkthrough writes all specified controls to the config."""
from algolia_agent.cli import cmd_init

providers = [{"id": "uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
inputs = iter([
"gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog.",
"y", # set up searchControls?
"10", # hitsPerPage max
"5", # page max
"title, price", # attributesToRetrieve
"brand, category", # facets
"hits, nbHits", # responseFields
])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
with _mock_init_client(providers):
with _mock_select(["hackathon-gemini", "products", "<done — no more replicas>"]):
with patch("builtins.input", lambda _: next(inputs)):
cmd_init(build_parser().parse_args(["init", "--output-dir", str(tmp_path)]))

config = json.loads((tmp_path / "agent-config.json").read_text())
sc = config["searchControls"]
assert sc["hitsPerPage"] == {"exposed": False, "default": 10, "constraint": {"max": 10}}
assert sc["page"] == {"exposed": False, "default": 0, "constraint": {"max": 5}}
assert sc["attributesToRetrieve"] == {"exposed": False, "default": ["title", "price"]}
assert sc["facets"] == {"exposed": False, "default": ["brand", "category"]}
assert sc["responseFields"] == {"exposed": False, "default": ["hits", "nbHits"]}


def test_init_skip_search_controls(tmp_path, monkeypatch):
"""When the user declines searchControls, no searchControls key is written."""
from algolia_agent.cli import cmd_init

providers = [{"id": "uuid", "name": "hackathon-gemini", "defaultModel": "gemini-2.5-flash"}]
inputs = iter(["gemini-2.5-flash", "My Agent", "PROMPT.md", "Product catalog.", "N"])
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
with _mock_init_client(providers):
with _mock_select(["hackathon-gemini", "products", "<done — no more replicas>"]):
with patch("builtins.input", lambda _: next(inputs)):
cmd_init(build_parser().parse_args(["init", "--output-dir", str(tmp_path)]))

config = json.loads((tmp_path / "agent-config.json").read_text())
assert "searchControls" not in config


# ── cmd_update ────────────────────────────────────────────────────────────────

def _make_current_agent(name="Old Agent", model="gemini-2.5-flash", instructions="Old instructions."):
Expand Down Expand Up @@ -821,7 +867,7 @@ def test_diff_detects_search_controls_change():
changes = _diff(current, new_payload)
assert len(changes) == 1
assert "searchControls" in changes[0]
assert "null" in changes[0] # current had no searchControls
assert "{}" in changes[0] # current had no matching searchControls keys


def test_diff_no_change_when_search_controls_equal():
Expand Down Expand Up @@ -857,3 +903,63 @@ def test_diff_detects_search_controls_change_on_replica():
changes = _diff(current, new_payload)
assert len(changes) == 1
assert "searchControls" in changes[0]


def test_diff_no_false_positive_when_api_expands_search_controls():
"""API-added fields (query, page, responseFields, etc.) do not cause a spurious diff."""
sc_config = {"hitsPerPage": {"exposed": False, "default": 10, "constraint": {"max": 10}}}
# The API returns this config plus extra default fields we never sent
sc_api = {
"hitsPerPage": {"exposed": False, "default": 10, "constraint": {"max": 10}},
"query": {"exposed": True, "default": None},
"page": {"exposed": True, "default": 0},
"responseFields": {"exposed": False, "default": None},
"facets": {"exposed": False, "default": None},
"custom": None,
}
current = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": sc_api}]}],
}
new_payload = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": sc_config}]}],
}
assert not _diff(current, new_payload)


def test_diff_detects_change_despite_api_expansion():
"""A real change to a config-specified field is still reported even when the API expanded the object."""
sc_api = {
"hitsPerPage": {"exposed": False, "default": 10, "constraint": {"max": 10}},
"query": {"exposed": True, "default": None},
"page": {"exposed": True, "default": 0},
}
sc_new = {"hitsPerPage": {"exposed": False, "default": 5, "constraint": {"max": 5}}}
current = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": sc_api}]}],
}
new_payload = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": sc_new}]}],
}
changes = _diff(current, new_payload)
assert len(changes) == 1
assert "searchControls" in changes[0]
Comment on lines +908 to +949


def test_diff_detects_clearing_search_controls():
"""Sending searchControls: {} when current has non-empty controls is reported as a change."""
sc_existing = {"hitsPerPage": {"exposed": False, "default": 10, "constraint": {"max": 10}}}
current = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": sc_existing}]}],
}
new_payload = {
"name": "My Agent", "model": "gemini-2.5-flash", "instructions": "Hello.",
"tools": [{"type": "algolia_search_index", "indices": [{"index": "products", "searchControls": {}}]}],
}
changes = _diff(current, new_payload)
assert len(changes) == 1
assert "searchControls" in changes[0]
Loading