Skip to content

Commit 4d1131f

Browse files
RafaelPoclaude
andauthored
Surface output artifact_id for operation chaining (#246)
Thread the output artifact_id from the API through progress messages and result responses so clients can chain operations (e.g. upload → agent → rank → screen) without re-uploading data. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3d5f6d9 commit 4d1131f

5 files changed

Lines changed: 247 additions & 8 deletions

File tree

everyrow-mcp/src/everyrow_mcp/result_store.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def _build_result_response(
9595
session_url: str = "",
9696
poll_token: str = "",
9797
mcp_server_url: str = "",
98+
artifact_id: str = "",
9899
*,
99100
requested_page_size: int | None = None,
100101
skip_widget: bool = False,
@@ -148,6 +149,8 @@ def _build_result_response(
148149
}
149150
if session_url:
150151
widget_data["session_url"] = session_url
152+
if artifact_id:
153+
widget_data["artifact_id"] = artifact_id
151154
if poll_token:
152155
widget_data["poll_token"] = poll_token
153156
widget_data["download_token_url"] = (
@@ -182,6 +185,9 @@ def _build_result_response(
182185
f"of {total} (final page)."
183186
)
184187

188+
if artifact_id:
189+
summary += f"\nOutput artifact_id (use to chain into next tool): {artifact_id}"
190+
185191
contents.append(TextContent(type="text", text=summary))
186192
return contents
187193

@@ -265,6 +271,7 @@ async def try_cached_result(
265271
session_url=meta.get("session_url", ""),
266272
poll_token=poll_token or "",
267273
mcp_server_url=mcp_server_url,
274+
artifact_id=meta.get("artifact_id", ""),
268275
requested_page_size=page_size,
269276
skip_widget=skip_widget,
270277
skip_session=skip_session,
@@ -278,6 +285,7 @@ async def try_store_result(
278285
page_size: int,
279286
session_url: str = "",
280287
mcp_server_url: str = "",
288+
artifact_id: str = "",
281289
*,
282290
skip_widget: bool = False,
283291
skip_session: bool = False,
@@ -294,6 +302,8 @@ async def try_store_result(
294302
meta: dict[str, Any] = {"total": total, "columns": columns}
295303
if session_url:
296304
meta["session_url"] = session_url
305+
if artifact_id:
306+
meta["artifact_id"] = artifact_id
297307
await redis_store.store_result_meta(task_id, json.dumps(meta))
298308

299309
# Build and cache page preview
@@ -328,6 +338,7 @@ async def try_store_result(
328338
session_url=session_url,
329339
poll_token=poll_token or "",
330340
mcp_server_url=mcp_server_url,
341+
artifact_id=artifact_id,
331342
requested_page_size=page_size,
332343
skip_widget=skip_widget,
333344
skip_session=skip_session,

everyrow-mcp/src/everyrow_mcp/tool_helpers.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,14 @@ def total(self) -> int:
373373
p = self._response.progress
374374
return p.total if p else 0
375375

376+
@computed_field
377+
@property
378+
def artifact_id(self) -> str:
379+
aid = self._response.artifact_id
380+
if aid is not None and not isinstance(aid, Unset):
381+
return str(aid)
382+
return ""
383+
376384
@computed_field
377385
@property
378386
def error(self) -> str | None:
@@ -443,6 +451,8 @@ def progress_message(self, task_id: str) -> str:
443451
After the user responds, call everyrow_results(task_id='{task_id}', page_size=N).""")
444452
else:
445453
next_call = f"Call everyrow_results(task_id='{task_id}', output_path='<choose_a_path>.csv') to save the output."
454+
if self.artifact_id:
455+
completed_msg += f"\nOutput artifact_id: {self.artifact_id}"
446456
return f"{completed_msg}\n{next_call}"
447457
return f"Task {self.status.value}. Report the error to the user."
448458

@@ -535,13 +545,15 @@ def __init__(self, status: str) -> None:
535545
super().__init__(status)
536546

537547

538-
async def _fetch_task_result(client: Any, task_id: str) -> tuple[pd.DataFrame, str]:
539-
"""Fetch a task's result DataFrame and session ID from the API.
548+
async def _fetch_task_result(
549+
client: Any, task_id: str
550+
) -> tuple[pd.DataFrame, str, str]:
551+
"""Fetch a task's result DataFrame, session ID, and output artifact ID from the API.
540552
541553
Checks task status first, then retrieves and parses the result data.
542554
543555
Returns:
544-
Tuple of (DataFrame, session_id).
556+
Tuple of (DataFrame, session_id, artifact_id).
545557
546558
Raises:
547559
TaskNotReady: If the task is not in a terminal state.
@@ -576,9 +588,18 @@ async def _fetch_task_result(client: Any, task_id: str) -> tuple[pd.DataFrame, s
576588
)
577589
)
578590

591+
artifact_id = ""
592+
aid = result_response.artifact_id
593+
if aid is not None and not isinstance(aid, Unset):
594+
artifact_id = str(aid)
595+
579596
if isinstance(result_response.data, list):
580597
records = [item.additional_properties for item in result_response.data]
581-
return pd.DataFrame(records), session_id
598+
return pd.DataFrame(records), session_id, artifact_id
582599
if isinstance(result_response.data, TaskResultResponseDataType1):
583-
return pd.DataFrame([result_response.data.additional_properties]), session_id
600+
return (
601+
pd.DataFrame([result_response.data.additional_properties]),
602+
session_id,
603+
artifact_id,
604+
)
584605
raise ValueError("Task result has no table data.")

everyrow-mcp/src/everyrow_mcp/tools.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ async def everyrow_results_stdio(
844844
task_id = params.task_id
845845

846846
try:
847-
df, _session_id = await _fetch_task_result(client, task_id)
847+
df, _session_id, artifact_id = await _fetch_task_result(client, task_id)
848848
except TaskNotReady as e:
849849
return [
850850
TextContent(
@@ -865,11 +865,12 @@ async def everyrow_results_stdio(
865865

866866
output_file = Path(params.output_path)
867867
save_result_to_csv(df, output_file)
868+
artifact_line = f"\nOutput artifact_id: {artifact_id}" if artifact_id else ""
868869
return [
869870
TextContent(
870871
type="text",
871872
text=dedent(f"""\
872-
Saved {len(df)} rows to {output_file}
873+
Saved {len(df)} rows to {output_file}{artifact_line}
873874
874875
Tip: For multi-step pipelines or custom response models, \
875876
use the everyrow Python SDK directly."""),
@@ -924,7 +925,7 @@ async def everyrow_results_http(
924925

925926
# ── Fetch from API ────────────────────────────────────────────
926927
try:
927-
df, session_id = await _fetch_task_result(client, task_id)
928+
df, session_id, artifact_id = await _fetch_task_result(client, task_id)
928929
session_url = get_session_url(UUID(session_id)) if session_id else ""
929930
except TaskNotReady as e:
930931
return [
@@ -955,6 +956,7 @@ async def everyrow_results_http(
955956
params.page_size,
956957
session_url,
957958
mcp_server_url=mcp_server_url,
959+
artifact_id=artifact_id,
958960
skip_widget=skip_widget,
959961
skip_session=skip_session,
960962
)

everyrow-mcp/tests/test_result_store.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,42 @@ def test_no_poll_token_when_empty(self):
251251
assert "poll_token" not in widget
252252
assert "download_token_url" not in widget
253253

254+
def test_artifact_id_included_in_widget_and_text(self):
255+
"""When artifact_id is provided, it appears in widget JSON and text summary."""
256+
preview = [{"a": 1}]
257+
csv_url = f"{FAKE_SERVER_URL}/api/results/task-aid/download?token=abc"
258+
result = _build_result_response(
259+
task_id="task-aid",
260+
csv_url=csv_url,
261+
preview_records=preview,
262+
total=1,
263+
columns=["a"],
264+
offset=0,
265+
page_size=10,
266+
artifact_id="abc-123-def",
267+
)
268+
widget = json.loads(result[0].text)
269+
assert widget["artifact_id"] == "abc-123-def"
270+
assert "abc-123-def" in result[1].text
271+
assert "Output artifact_id" in result[1].text
272+
273+
def test_no_artifact_id_when_empty(self):
274+
"""When artifact_id is empty, widget JSON and text omit it."""
275+
preview = [{"a": 1}]
276+
csv_url = f"{FAKE_SERVER_URL}/api/results/task-noaid/download?token=abc"
277+
result = _build_result_response(
278+
task_id="task-noaid",
279+
csv_url=csv_url,
280+
preview_records=preview,
281+
total=1,
282+
columns=["a"],
283+
offset=0,
284+
page_size=10,
285+
)
286+
widget = json.loads(result[0].text)
287+
assert "artifact_id" not in widget
288+
assert "Output artifact_id" not in result[1].text
289+
254290

255291
# ── Async functions ────────────────────────────────────────────
256292

@@ -325,6 +361,29 @@ async def test_preserves_session_url_from_meta(self, _http_state):
325361
widget = json.loads(result[0].text)
326362
assert widget["session_url"] == "https://everyrow.io/sessions/xyz"
327363

364+
@pytest.mark.asyncio
365+
async def test_preserves_artifact_id_from_meta(self, _http_state):
366+
meta = json.dumps(
367+
{
368+
"total": 1,
369+
"columns": ["a"],
370+
"artifact_id": "cached-artifact-789",
371+
}
372+
)
373+
page = json.dumps([{"a": 1}])
374+
task_id = "task-cached-aid"
375+
376+
await redis_store.store_result_meta(task_id, meta)
377+
await redis_store.store_result_page(task_id, 0, 10, page)
378+
await redis_store.store_poll_token(task_id, "test-token")
379+
380+
result = await try_cached_result(task_id, 0, 10, mcp_server_url=FAKE_SERVER_URL)
381+
382+
assert result is not None
383+
widget = json.loads(result[0].text)
384+
assert widget["artifact_id"] == "cached-artifact-789"
385+
assert "cached-artifact-789" in result[1].text
386+
328387
@pytest.mark.asyncio
329388
async def test_returns_none_when_json_expired(self, _http_state):
330389
"""When metadata exists but JSON is gone, fall back to API (return None)."""
@@ -383,6 +442,35 @@ async def test_stores_and_returns_response(self, sample_df, _http_state):
383442
assert meta["total"] == 3
384443
assert meta["columns"] == ["name", "score"]
385444

445+
@pytest.mark.asyncio
446+
async def test_stores_artifact_id_in_meta_and_response(
447+
self, sample_df, _http_state
448+
):
449+
task_id = "task-aid-store"
450+
await redis_store.store_poll_token(task_id, "test-token")
451+
452+
result = await try_store_result(
453+
task_id,
454+
sample_df,
455+
0,
456+
10,
457+
mcp_server_url=FAKE_SERVER_URL,
458+
artifact_id="output-artifact-456",
459+
)
460+
461+
# Verify artifact_id in Redis metadata
462+
meta_raw = await redis_store.get_result_meta(task_id)
463+
assert meta_raw is not None
464+
meta = json.loads(meta_raw)
465+
assert meta["artifact_id"] == "output-artifact-456"
466+
467+
# Verify artifact_id in widget JSON
468+
widget = json.loads(result[0].text)
469+
assert widget["artifact_id"] == "output-artifact-456"
470+
471+
# Verify artifact_id in text summary
472+
assert "output-artifact-456" in result[1].text
473+
386474
@pytest.mark.asyncio
387475
async def test_includes_session_url_in_meta(self, sample_df, _http_state):
388476
task_id = "task-sess"
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for tool_helpers.py — TaskState, _fetch_task_result, progress_message."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock
6+
from uuid import uuid4
7+
8+
from everyrow.generated.models.public_task_type import PublicTaskType
9+
from everyrow.generated.models.task_status import TaskStatus
10+
from everyrow.generated.types import UNSET
11+
12+
from everyrow_mcp.tool_helpers import TaskState
13+
from tests.conftest import override_settings
14+
15+
16+
def _make_status_response(
17+
*,
18+
status: TaskStatus = TaskStatus.COMPLETED,
19+
task_type: PublicTaskType = PublicTaskType.AGENT,
20+
artifact_id=UNSET,
21+
session_id=None,
22+
error=UNSET,
23+
progress=None,
24+
created_at=None,
25+
updated_at=None,
26+
):
27+
"""Build a mock TaskStatusResponse."""
28+
resp = MagicMock()
29+
resp.status = status
30+
resp.task_type = task_type
31+
resp.artifact_id = artifact_id
32+
resp.session_id = session_id
33+
resp.error = error
34+
resp.created_at = created_at
35+
resp.updated_at = updated_at
36+
37+
if progress is None:
38+
p = MagicMock()
39+
p.completed = 5
40+
p.failed = 0
41+
p.running = 0
42+
p.total = 5
43+
resp.progress = p
44+
else:
45+
resp.progress = progress
46+
47+
return resp
48+
49+
50+
class TestTaskStateArtifactId:
51+
def test_artifact_id_from_uuid(self):
52+
uid = uuid4()
53+
resp = _make_status_response(artifact_id=uid)
54+
ts = TaskState(resp)
55+
assert ts.artifact_id == str(uid)
56+
57+
def test_artifact_id_unset(self):
58+
resp = _make_status_response(artifact_id=UNSET)
59+
ts = TaskState(resp)
60+
assert ts.artifact_id == ""
61+
62+
def test_artifact_id_none(self):
63+
resp = _make_status_response(artifact_id=None)
64+
ts = TaskState(resp)
65+
assert ts.artifact_id == ""
66+
67+
68+
class TestProgressMessageArtifactId:
69+
def test_completed_message_includes_artifact_id(self):
70+
uid = uuid4()
71+
resp = _make_status_response(
72+
status=TaskStatus.COMPLETED,
73+
artifact_id=uid,
74+
)
75+
ts = TaskState(resp)
76+
msg = ts.progress_message("task-123")
77+
assert f"Output artifact_id: {uid}" in msg
78+
79+
def test_completed_message_omits_artifact_id_when_absent(self):
80+
resp = _make_status_response(
81+
status=TaskStatus.COMPLETED,
82+
artifact_id=UNSET,
83+
)
84+
ts = TaskState(resp)
85+
msg = ts.progress_message("task-123")
86+
assert "Output artifact_id" not in msg
87+
88+
def test_completed_http_mode_includes_artifact_id(self):
89+
uid = uuid4()
90+
resp = _make_status_response(
91+
status=TaskStatus.COMPLETED,
92+
artifact_id=uid,
93+
)
94+
ts = TaskState(resp)
95+
with override_settings(transport="streamable-http"):
96+
msg = ts.progress_message("task-456")
97+
assert f"Output artifact_id: {uid}" in msg
98+
assert "everyrow_results" in msg
99+
100+
def test_running_message_does_not_include_artifact_id(self):
101+
uid = uuid4()
102+
resp = _make_status_response(
103+
status=TaskStatus.RUNNING,
104+
artifact_id=uid,
105+
)
106+
ts = TaskState(resp)
107+
msg = ts.progress_message("task-789")
108+
assert "Output artifact_id" not in msg
109+
110+
def test_failed_message_does_not_include_artifact_id(self):
111+
resp = _make_status_response(
112+
status=TaskStatus.FAILED,
113+
error="Something went wrong",
114+
)
115+
ts = TaskState(resp)
116+
msg = ts.progress_message("task-err")
117+
assert "Output artifact_id" not in msg

0 commit comments

Comments
 (0)