Skip to content

Commit 94a3e70

Browse files
nikosbossegithub-actions[bot]
authored andcommitted
CLAUDE: agent_map list output (return_table) (#5531)
## Summary CLAUDE: Adds a `return_list` (api_v0) / `return_table` (SDK) option to `agent_map`, mirroring the existing flag on `single_agent`. When enabled, each per-row agent emits a list of records that fans out into multiple output rows (with an `_expand_index` column). This restores the "map + expand" capability that earlier Cohort code exposed. The internal `AgentQueryParams.is_expand` path and ClickHouse partial-table ingestion already supported MAP+expand for the autocohort path — only the public API surface gated it off. ### Changes **Engine (api_v0):** - `data_types/operations.py` — added `return_list: bool = False` field to `AgentMapOperation` (mirrors `SingleAgentOperation`). - `conversions.py` — both request types now flow `return_list → AgentQueryParams.is_expand`. Removed the explicit `isinstance(SingleAgentOperation)` gate. **SDK:** - `ops.py` — threaded `return_table: bool = False` through `agent_map`, `agent_map_async`, and `_submit_agent_map`. Forwards as `return_list` on the wire and flips `EveryrowTask.is_expand` so the result is unpacked correctly. - `generated/models/agent_map_operation.py` — regenerated from the updated OpenAPI spec. - `README.md` — added a `return_table=True` example. **Tests:** - 4 new handler tests on `/operations/agent-map` (default false, explicit false, explicit true, column-collision contract under `join_with_input=True`). - 5 new unit tests on `agent_operation_to_agent_query_params` covering both request types. - Replaced misleading `test_agent_map_with_table_output` (didn't actually exercise the flag) with two tests asserting `return_list` is forwarded and the result fans out (3 items × 2 input rows → 6 output rows). ### What's not in this PR - **SDK version bump.** Per the `bump-sdk-version` skill, version bumps go in their own dedicated PR. Should be a follow-up after this lands. ### Schema contract (worth calling out) When `return_table=True`, users pass the **per-item** `response_schema` — not a pre-wrapped `{items: [...]}` schema. The worker calls `wrap_json_schema_in_list` at execution time; ClickHouse unwraps via `_extract_item_schema`. Documented in the field description and SDK docstring. ## Test plan - [x] `uv run pytest tests/server/api_v0/test_operations.py tests/server/api_v0/test_conversions.py` — 136 passed - [x] `uv run pytest tests/test_ops.py` (futuresearch-python) — 13 passed - [x] `uv run ruff check` and `uv run pyright` clean on touched files - [ ] Smoke-test against staging once merged: submit an `agent_map` with `return_list=true` and a small per-item schema, confirm row count > input row count and `_expand_index` column is present. - [ ] Autocohort regression spot-check — same `query_params` shape lands in DB regardless of submission path, but worth running the autocohort tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Sourced from commit 09d429c61cfb923102ed411a88d641af6699b1dc
1 parent f7f58cf commit 94a3e70

4 files changed

Lines changed: 92 additions & 9 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ result = await agent_map(
8484
]),
8585
)
8686
print(result.data.head())
87+
88+
# Same map, but each agent emits a list of records that fan out into extra rows
89+
# (one row per item, with an `_expand_index` column).
90+
result = await agent_map(
91+
task="List this company's top 5 products",
92+
input=DataFrame([
93+
{"company": "Anthropic"},
94+
{"company": "OpenAI"},
95+
]),
96+
return_table=True,
97+
)
98+
print(result.data.head())
8799
```
88100

89101
See the API [docs](https://futuresearch.ai/docs/reference/RESEARCH), a case study of [labeling data](https://futuresearch.ai/docs/classify-dataframe-rows-llm) or a case study for [researching government data](https://futuresearch.ai/docs/case-studies/research-and-rank-permit-times) at scale.

src/futuresearch/generated/models/agent_map_operation.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ class AgentMapOperation:
5151
more important than overall throughput. Default: False.
5252
document_query_llm (LLMEnumPublic | None | Unset): LLM to use for the document query tool (QDLLM) that reads and
5353
extracts information from web pages. If not provided, defaults to the system default.
54+
return_list (bool | Unset): If True, treat each row's agent output as a list of records and emit one output row
55+
per item (with an `_expand_index` column). The `response_schema` should describe a single item; the worker wraps
56+
it in a list automatically. Do not pre-wrap your schema. Default: False.
5457
"""
5558

5659
input_: AgentMapOperationInputType2 | list[AgentMapOperationInputType1Item] | UUID
@@ -66,6 +69,7 @@ class AgentMapOperation:
6669
include_research: bool | None | Unset = UNSET
6770
enforce_row_independence: bool | Unset = False
6871
document_query_llm: LLMEnumPublic | None | Unset = UNSET
72+
return_list: bool | Unset = False
6973
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
7074

7175
def to_dict(self) -> dict[str, Any]:
@@ -153,6 +157,8 @@ def to_dict(self) -> dict[str, Any]:
153157
else:
154158
document_query_llm = self.document_query_llm
155159

160+
return_list = self.return_list
161+
156162
field_dict: dict[str, Any] = {}
157163
field_dict.update(self.additional_properties)
158164
field_dict.update(
@@ -183,6 +189,8 @@ def to_dict(self) -> dict[str, Any]:
183189
field_dict["enforce_row_independence"] = enforce_row_independence
184190
if document_query_llm is not UNSET:
185191
field_dict["document_query_llm"] = document_query_llm
192+
if return_list is not UNSET:
193+
field_dict["return_list"] = return_list
186194

187195
return field_dict
188196

@@ -351,6 +359,8 @@ def _parse_document_query_llm(data: object) -> LLMEnumPublic | None | Unset:
351359

352360
document_query_llm = _parse_document_query_llm(d.pop("document_query_llm", UNSET))
353361

362+
return_list = d.pop("return_list", UNSET)
363+
354364
agent_map_operation = cls(
355365
input_=input_,
356366
task=task,
@@ -365,6 +375,7 @@ def _parse_document_query_llm(data: object) -> LLMEnumPublic | None | Unset:
365375
include_research=include_research,
366376
enforce_row_independence=enforce_row_independence,
367377
document_query_llm=document_query_llm,
378+
return_list=return_list,
368379
)
369380

370381
agent_map_operation.additional_properties = d

src/futuresearch/ops.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ async def agent_map(
324324
enforce_row_independence: bool = False,
325325
response_model: type[BaseModel] = DefaultAgentResponse,
326326
document_query_llm: LLM | None = None,
327+
return_table: bool = False,
327328
) -> TableResult:
328329
"""Execute an AI agent task on each row of the input table.
329330
@@ -336,8 +337,12 @@ async def agent_map(
336337
llm: LLM to use for each agent. Required when effort_level is None.
337338
iteration_budget: Number of agent iterations per row (0-20). Required when effort_level is None.
338339
include_reasoning: Include reasoning notes. Required when effort_level is None.
339-
response_model: Pydantic model for the response schema.
340+
response_model: Pydantic model for the response schema. When ``return_table`` is True,
341+
this should describe a single item; the worker wraps it in a list automatically.
340342
document_query_llm: LLM to use for the document query tool (QDLLM) when scraping web pages.
343+
return_table: If True, each per-row agent emits a list of records and the result table
344+
contains one row per item (with an ``_expand_index`` column). Output rows can exceed
345+
input rows. Default: False (one output row per input row).
341346
342347
Returns:
343348
TableResult containing the agent results merged with input rows.
@@ -357,6 +362,7 @@ async def agent_map(
357362
enforce_row_independence=enforce_row_independence,
358363
response_model=response_model,
359364
document_query_llm=document_query_llm,
365+
return_table=return_table,
360366
)
361367
result = await cohort_task.await_result()
362368
if isinstance(result, TableResult):
@@ -373,6 +379,7 @@ async def agent_map(
373379
enforce_row_independence=enforce_row_independence,
374380
response_model=response_model,
375381
document_query_llm=document_query_llm,
382+
return_table=return_table,
376383
)
377384
result = await cohort_task.await_result()
378385
if isinstance(result, TableResult):
@@ -391,6 +398,7 @@ async def _submit_agent_map(
391398
enforce_row_independence: bool = False,
392399
response_schema: dict | None = None,
393400
document_query_llm: LLM | None = None,
401+
return_table: bool = False,
394402
) -> SubmittedTask:
395403
"""Build and submit an agent_map request."""
396404
input_data = _prepare_table_input(input, AgentMapOperationInputType1Item)
@@ -413,6 +421,7 @@ async def _submit_agent_map(
413421
document_query_llm=LLMEnumPublic(document_query_llm.value)
414422
if document_query_llm is not None
415423
else UNSET,
424+
return_list=return_table,
416425
)
417426

418427
response = await agent_map_operations_agent_map_post.asyncio(
@@ -433,6 +442,7 @@ async def agent_map_async(
433442
enforce_row_independence: bool = False,
434443
response_model: type[BaseModel] = DefaultAgentResponse,
435444
document_query_llm: LLM | None = None,
445+
return_table: bool = False,
436446
) -> EveryrowTask[BaseModel]:
437447
"""Submit an agent_map task asynchronously."""
438448
submitted = await _submit_agent_map(
@@ -446,10 +456,11 @@ async def agent_map_async(
446456
enforce_row_independence=enforce_row_independence,
447457
response_schema=response_model.model_json_schema(),
448458
document_query_llm=document_query_llm,
459+
return_table=return_table,
449460
)
450461

451462
cohort_task = EveryrowTask(
452-
response_model=response_model, is_map=True, is_expand=False
463+
response_model=response_model, is_map=True, is_expand=return_table
453464
)
454465
cohort_task.set_submitted(submitted.task_id, submitted.session_id, session.client)
455466
return cohort_task

tests/test_ops.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,11 @@ async def test_agent_map(mocker, mock_session):
263263

264264

265265
@pytest.mark.asyncio
266-
async def test_agent_map_with_table_output(mocker, mock_session):
266+
async def test_agent_map_with_return_table_forwards_return_list(mocker, mock_session):
267+
"""When return_table=True, agent_map sends return_list=True and accepts a fan-out result."""
267268
task_id = uuid.uuid4()
268269
artifact_id = uuid.uuid4()
269270

270-
# Mock operation endpoint
271271
mock_submit = mocker.patch(
272272
"futuresearch.ops.agent_map_operations_agent_map_post.asyncio",
273273
new_callable=AsyncMock,
@@ -278,7 +278,6 @@ async def test_agent_map_with_table_output(mocker, mock_session):
278278
status=TaskStatus.PENDING,
279279
)
280280

281-
# Mock get_task_status
282281
mock_status = mocker.patch(
283282
"futuresearch.task.get_task_status_tasks_task_id_status_get.asyncio",
284283
new_callable=AsyncMock,
@@ -287,16 +286,20 @@ async def test_agent_map_with_table_output(mocker, mock_session):
287286
task_id, mock_session.session_id, artifact_id
288287
)
289288

290-
# Mock get_task_result
289+
# Two input rows; agent fans each out into 3 cities, so 6 output rows total.
291290
mock_result = mocker.patch(
292291
"futuresearch.task.get_task_result_tasks_task_id_result_get.asyncio",
293292
new_callable=AsyncMock,
294293
)
295294
mock_result.return_value = _make_table_result(
296295
task_id,
297296
[
298-
{"country": "India", "city": "Mumbai"},
299-
{"country": "USA", "city": "New York"},
297+
{"country": "India", "city": "Mumbai", "_expand_index": 0},
298+
{"country": "India", "city": "Delhi", "_expand_index": 1},
299+
{"country": "India", "city": "Bangalore", "_expand_index": 2},
300+
{"country": "USA", "city": "New York", "_expand_index": 0},
301+
{"country": "USA", "city": "Los Angeles", "_expand_index": 1},
302+
{"country": "USA", "city": "Chicago", "_expand_index": 2},
300303
],
301304
artifact_id,
302305
)
@@ -306,13 +309,59 @@ async def test_agent_map_with_table_output(mocker, mock_session):
306309
task="What are the three largest cities in the given country?",
307310
session=mock_session,
308311
input=input_df,
312+
return_table=True,
309313
)
310314

315+
# Body sent to the API carries return_list=True.
316+
submit_kwargs = mock_submit.await_args.kwargs
317+
assert submit_kwargs["body"].return_list is True
318+
311319
assert isinstance(result, TableResult)
312-
assert len(result.data) == 2
320+
assert len(result.data) == 6
321+
assert "city" in result.data.columns
313322
assert result.artifact_id == artifact_id
314323

315324

325+
@pytest.mark.asyncio
326+
async def test_agent_map_default_does_not_set_return_list(mocker, mock_session):
327+
task_id = uuid.uuid4()
328+
artifact_id = uuid.uuid4()
329+
330+
mock_submit = mocker.patch(
331+
"futuresearch.ops.agent_map_operations_agent_map_post.asyncio",
332+
new_callable=AsyncMock,
333+
)
334+
mock_submit.return_value = OperationResponse(
335+
task_id=task_id,
336+
session_id=mock_session.session_id,
337+
status=TaskStatus.PENDING,
338+
)
339+
340+
mocker.patch(
341+
"futuresearch.task.get_task_status_tasks_task_id_status_get.asyncio",
342+
new_callable=AsyncMock,
343+
return_value=_make_status_response(
344+
task_id, mock_session.session_id, artifact_id
345+
),
346+
)
347+
mocker.patch(
348+
"futuresearch.task.get_task_result_tasks_task_id_result_get.asyncio",
349+
new_callable=AsyncMock,
350+
return_value=_make_table_result(
351+
task_id, [{"country": "India", "answer": "New Delhi"}], artifact_id
352+
),
353+
)
354+
355+
await agent_map(
356+
task="capital?",
357+
session=mock_session,
358+
input=pd.DataFrame([{"country": "India"}]),
359+
)
360+
361+
submit_kwargs = mock_submit.await_args.kwargs
362+
assert submit_kwargs["body"].return_list is False
363+
364+
316365
@pytest.mark.asyncio
317366
async def test_rank_model_validation(mock_session) -> None:
318367
input_df = pd.DataFrame(

0 commit comments

Comments
 (0)