ctxprotocol 0.17.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/.gitignore +1 -0
  2. ctxprotocol-0.19.0/CHANGELOG.md +13 -0
  3. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/PKG-INFO +23 -5
  4. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/README.md +22 -4
  5. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/__init__.py +7 -1
  6. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/__init__.py +8 -0
  7. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/resources/query.py +114 -0
  8. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/types.py +73 -21
  9. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/pyproject.toml +1 -1
  10. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/test_query.py +136 -0
  11. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/uv.lock +1 -1
  12. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/.codexignore +0 -0
  13. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/auth/__init__.py +0 -0
  14. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/client.py +0 -0
  15. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/resources/__init__.py +0 -0
  16. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/resources/developer.py +0 -0
  17. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/resources/discovery.py +0 -0
  18. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/client/resources/tools.py +0 -0
  19. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/context/__init__.py +0 -0
  20. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/context/hyperliquid.py +0 -0
  21. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/context/polymarket.py +0 -0
  22. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/context/wallet.py +0 -0
  23. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/__init__.py +0 -0
  24. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/search/__init__.py +0 -0
  25. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/search/core.py +0 -0
  26. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/search/trace.py +0 -0
  27. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/search/types.py +0 -0
  28. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/contrib/search/validation.py +0 -0
  29. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/handshake/__init__.py +0 -0
  30. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/ctxprotocol/py.typed +0 -0
  31. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/docs/README.md +0 -0
  32. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/execute_client.py +0 -0
  33. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/polymarket_query_trace_validation.py +0 -0
  34. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/query_client.py +0 -0
  35. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/test_favorites_only.py +0 -0
  36. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/test_get_tool.py +0 -0
  37. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/two_surfaces_client.py +0 -0
  38. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/generate_contributor_search_validation_artifacts.py +0 -0
  39. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/named-regression-iran-boots-on-ground.json +0 -0
  40. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/named-regression-supreme-court-tariffs.json +0 -0
  41. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/shared-capability-miss-unsupported-venue.json +0 -0
  42. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/shared-generic-overlap-best-match.json +0 -0
  43. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/client/validation/shared-still-ambiguous-shortlist.json +0 -0
  44. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/server/hummingbot-contributor/README.md +0 -0
  45. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/server/hummingbot-contributor/env.example +0 -0
  46. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/server/hummingbot-contributor/requirements.txt +0 -0
  47. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/examples/server/hummingbot-contributor/server.py +0 -0
  48. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/polymarket-query-trace-results-py.json +0 -0
  49. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/__init__.py +0 -0
  50. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/contrib_search_validation_cases.py +0 -0
  51. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/test_client.py +0 -0
  52. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/test_contrib_search.py +0 -0
  53. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/test_discovery.py +0 -0
  54. {ctxprotocol-0.17.0 → ctxprotocol-0.19.0}/tests/test_tools.py +0 -0
@@ -43,6 +43,7 @@ htmlcov/
43
43
  # md files
44
44
  *.md
45
45
  !README.md
46
+ !CHANGELOG.md
46
47
  !docs/mcp-builder-template.md
47
48
  !docs/mcp-server-analysis-prompt.md
48
49
  !docs/mcp-contributor-deep-validation-system-prompt.md
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.19.0
4
+
5
+ - Fixed `client.query.run()`/`client.query.stream()` raising a `ValidationError` on queries that returned a rendered image artifact (e.g. server-rendered chart PNGs). `computedArtifacts` now accepts both `chart` (structured spec + data) and `image` (hosted URL) variants.
6
+ - Added `QueryImageArtifact` (`kind: "image"` with `url`, `alt`, `title`, `content_hash`, `bytes`, `width`, `height`) and made `QueryComputedArtifact` a discriminated union over `kind`.
7
+ - Exported `QueryImageArtifact` from the client entry point alongside `QueryChartArtifact`.
8
+
9
+ ## 0.18.0
10
+
11
+ - Added durable async Query jobs with `client.query.start()`, `client.query.get_status()`, and `client.query.poll()`.
12
+ - Exported `QueryJobStartResult`, `QueryJobStatusResult`, and `QueryJobStatus` from the root and client entry points.
13
+ - Kept existing `client.query.run()` and `client.query.stream()` behavior unchanged.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxprotocol
3
- Version: 0.17.0
3
+ Version: 0.19.0
4
4
  Summary: Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically
5
5
  Project-URL: Homepage, https://ctxprotocol.com
6
6
  Project-URL: Documentation, https://docs.ctxprotocol.com
@@ -149,6 +149,24 @@ print(
149
149
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
150
150
  ```
151
151
 
152
+ For long-running questions, start a durable job and poll until it completes:
153
+
154
+ ```python
155
+ job = await client.query.start(
156
+ query="Build a chart-ready dataset of Base whale movements over the last 30 days",
157
+ response_shape="evidence_only",
158
+ include_data_url=True,
159
+ )
160
+
161
+ completed = await client.query.poll(
162
+ job.job_id,
163
+ interval_ms=2000,
164
+ timeout_ms=15 * 60_000,
165
+ )
166
+
167
+ print(completed.result.data_url if completed.result else None)
168
+ ```
169
+
152
170
  To see valid model slugs without guessing:
153
171
 
154
172
  ```python
@@ -166,7 +184,7 @@ print(AGENT_MODEL_IDS) # Supported agent_model_id values
166
184
  `response_shape` options:
167
185
 
168
186
  - `answer_with_evidence` (default): prose plus `summary`, `evidence`, `artifacts`, `freshness`, `confidence`, `usage`, `outcome`, and `controller`
169
- - `evidence_only`: raw fetched data, computed artifacts, and provenance for downstream agents (no prose synthesis)
187
+ - `evidence_only`: bounded evidence, computed artifacts, and full-data references for downstream agents (no prose synthesis)
170
188
 
171
189
  Premium wedge answers can also expose `evidence.market_intelligence`, `view.rows`, `view.columns`, and the top-level controller fields `stop_reason`, `issue_class`, and `actions_taken`.
172
190
 
@@ -327,7 +345,7 @@ answer = await client.query.run(
327
345
  query="Analyze whale activity on Base",
328
346
  tools=["tool-uuid-1", "tool-uuid-2"], # optional — auto-discover if omitted
329
347
  agent_model_id="kimi-k2.6-model", # optional main librarian agent model
330
- include_data=True, # optional: include execution data inline
348
+ include_data=True, # optional: include bounded execution data inline
331
349
  include_data_url=True, # optional: include blob URL for full data
332
350
  include_developer_trace=True, # optional: include Developer Mode trace
333
351
  )
@@ -336,7 +354,7 @@ print(answer.response) # response text or summary
336
354
  print(answer.tools_used) # [QueryToolUsage(id, name, skill_calls)]
337
355
  print(answer.cost) # QueryCost(model_cost_usd, tool_cost_usd, total_cost_usd)
338
356
  print(answer.duration_ms) # Total time
339
- print(answer.data) # Optional execution data (when include_data=True)
357
+ print(answer.data) # Optional bounded data or truncation preview
340
358
  print(answer.data_url) # Optional blob URL (when include_data_url=True)
341
359
  print(answer.developer_trace.summary if answer.developer_trace else None)
342
360
  print(
@@ -347,7 +365,7 @@ print(
347
365
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
348
366
  ```
349
367
 
350
- When retrieval-first synthesis rollout is enabled server-side, full-data or truncation-sensitive query requests can switch to retrieval-first context assembly using private stage artifacts and canonical execution data slices. `include_data` and `include_data_url` continue to reference the same canonical dataset used for synthesis.
368
+ When retrieval-first synthesis rollout is enabled server-side, full-data or truncation-sensitive query requests can switch to retrieval-first context assembly using private stage artifacts and canonical execution data slices. `include_data` returns a bounded inline preview when needed, and `include_data_url`/`artifacts.canonical_data_ref` reference the same canonical dataset used for synthesis.
351
369
 
352
370
  #### `client.query.stream(query, tools?, agent_model_id?, include_data?, include_data_url?, include_developer_trace?, idempotency_key?)`
353
371
 
@@ -111,6 +111,24 @@ print(
111
111
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
112
112
  ```
113
113
 
114
+ For long-running questions, start a durable job and poll until it completes:
115
+
116
+ ```python
117
+ job = await client.query.start(
118
+ query="Build a chart-ready dataset of Base whale movements over the last 30 days",
119
+ response_shape="evidence_only",
120
+ include_data_url=True,
121
+ )
122
+
123
+ completed = await client.query.poll(
124
+ job.job_id,
125
+ interval_ms=2000,
126
+ timeout_ms=15 * 60_000,
127
+ )
128
+
129
+ print(completed.result.data_url if completed.result else None)
130
+ ```
131
+
114
132
  To see valid model slugs without guessing:
115
133
 
116
134
  ```python
@@ -128,7 +146,7 @@ print(AGENT_MODEL_IDS) # Supported agent_model_id values
128
146
  `response_shape` options:
129
147
 
130
148
  - `answer_with_evidence` (default): prose plus `summary`, `evidence`, `artifacts`, `freshness`, `confidence`, `usage`, `outcome`, and `controller`
131
- - `evidence_only`: raw fetched data, computed artifacts, and provenance for downstream agents (no prose synthesis)
149
+ - `evidence_only`: bounded evidence, computed artifacts, and full-data references for downstream agents (no prose synthesis)
132
150
 
133
151
  Premium wedge answers can also expose `evidence.market_intelligence`, `view.rows`, `view.columns`, and the top-level controller fields `stop_reason`, `issue_class`, and `actions_taken`.
134
152
 
@@ -289,7 +307,7 @@ answer = await client.query.run(
289
307
  query="Analyze whale activity on Base",
290
308
  tools=["tool-uuid-1", "tool-uuid-2"], # optional — auto-discover if omitted
291
309
  agent_model_id="kimi-k2.6-model", # optional main librarian agent model
292
- include_data=True, # optional: include execution data inline
310
+ include_data=True, # optional: include bounded execution data inline
293
311
  include_data_url=True, # optional: include blob URL for full data
294
312
  include_developer_trace=True, # optional: include Developer Mode trace
295
313
  )
@@ -298,7 +316,7 @@ print(answer.response) # response text or summary
298
316
  print(answer.tools_used) # [QueryToolUsage(id, name, skill_calls)]
299
317
  print(answer.cost) # QueryCost(model_cost_usd, tool_cost_usd, total_cost_usd)
300
318
  print(answer.duration_ms) # Total time
301
- print(answer.data) # Optional execution data (when include_data=True)
319
+ print(answer.data) # Optional bounded data or truncation preview
302
320
  print(answer.data_url) # Optional blob URL (when include_data_url=True)
303
321
  print(answer.developer_trace.summary if answer.developer_trace else None)
304
322
  print(
@@ -309,7 +327,7 @@ print(
309
327
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
310
328
  ```
311
329
 
312
- When retrieval-first synthesis rollout is enabled server-side, full-data or truncation-sensitive query requests can switch to retrieval-first context assembly using private stage artifacts and canonical execution data slices. `include_data` and `include_data_url` continue to reference the same canonical dataset used for synthesis.
330
+ When retrieval-first synthesis rollout is enabled server-side, full-data or truncation-sensitive query requests can switch to retrieval-first context assembly using private stage artifacts and canonical execution data slices. `include_data` returns a bounded inline preview when needed, and `include_data_url`/`artifacts.canonical_data_ref` reference the same canonical dataset used for synthesis.
313
331
 
314
332
  #### `client.query.stream(query, tools?, agent_model_id?, include_data?, include_data_url?, include_developer_trace?, idempotency_key?)`
315
333
 
@@ -31,7 +31,7 @@ Example:
31
31
  For more information, visit: https://ctxprotocol.com
32
32
  """
33
33
 
34
- __version__ = "0.17.0"
34
+ __version__ = "0.19.0"
35
35
 
36
36
  # Re-export everything from client module
37
37
  from ctxprotocol.client import (
@@ -70,6 +70,9 @@ from ctxprotocol.client.types import (
70
70
  QueryDeveloperTraceSummary,
71
71
  QueryDeveloperTraceStep,
72
72
  QueryDeveloperTraceToolRef,
73
+ QueryJobStartResult,
74
+ QueryJobStatus,
75
+ QueryJobStatusResult,
73
76
  QueryOrchestrationMetrics,
74
77
  QueryDeveloperTraceLoopInfo,
75
78
  QueryStreamDeveloperTraceEvent,
@@ -195,6 +198,9 @@ __all__ = [
195
198
  "QueryDeveloperTraceSummary",
196
199
  "QueryDeveloperTraceStep",
197
200
  "QueryDeveloperTraceToolRef",
201
+ "QueryJobStartResult",
202
+ "QueryJobStatus",
203
+ "QueryJobStatusResult",
198
204
  "QueryOrchestrationMetrics",
199
205
  "QueryDeveloperTraceLoopInfo",
200
206
  "QueryApiSuccessResponse",
@@ -44,6 +44,7 @@ from ctxprotocol.client.types import (
44
44
  QueryChartType,
45
45
  QueryChartValueFormat,
46
46
  QueryComputedArtifact,
47
+ QueryImageArtifact,
47
48
  QueryCost,
48
49
  QueryCompletenessRepairEvent,
49
50
  QueryDeveloperTrace,
@@ -51,6 +52,9 @@ from ctxprotocol.client.types import (
51
52
  QueryDeveloperTraceSummary,
52
53
  QueryDeveloperTraceStep,
53
54
  QueryDeveloperTraceToolRef,
55
+ QueryJobStartResult,
56
+ QueryJobStatus,
57
+ QueryJobStatusResult,
54
58
  QueryOrchestrationMetrics,
55
59
  QueryDeveloperTraceLoopInfo,
56
60
  QueryStreamDeveloperTraceEvent,
@@ -111,6 +115,9 @@ __all__ = [
111
115
  "QueryDeveloperTraceSummary",
112
116
  "QueryDeveloperTraceStep",
113
117
  "QueryDeveloperTraceToolRef",
118
+ "QueryJobStartResult",
119
+ "QueryJobStatus",
120
+ "QueryJobStatusResult",
114
121
  "QueryOrchestrationMetrics",
115
122
  "QueryDeveloperTraceLoopInfo",
116
123
  "QueryApiSuccessResponse",
@@ -127,6 +134,7 @@ __all__ = [
127
134
  "QueryChartType",
128
135
  "QueryChartValueFormat",
129
136
  "QueryComputedArtifact",
137
+ "QueryImageArtifact",
130
138
  "QueryStreamToolStatusEvent",
131
139
  "QueryStreamTextDeltaEvent",
132
140
  "QueryStreamDeveloperTraceEvent",
@@ -9,6 +9,7 @@ execute -> synthesize -> settle) and AI synthesis — all for one flat fee.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import asyncio
12
13
  import json
13
14
  from typing import TYPE_CHECKING, Any, AsyncGenerator
14
15
 
@@ -18,6 +19,8 @@ from ctxprotocol.client.types import (
18
19
  QueryAttemptReference,
19
20
  QueryDeveloperTrace,
20
21
  QueryForkReference,
22
+ QueryJobStartResult,
23
+ QueryJobStatusResult,
21
24
  QueryResponseShape,
22
25
  QueryResult,
23
26
  QueryStreamDeveloperTraceEvent,
@@ -43,6 +46,52 @@ class Query:
43
46
  """
44
47
  self._client = client
45
48
 
49
+ @staticmethod
50
+ def _build_request_body(
51
+ query: str,
52
+ tools: list[str] | None = None,
53
+ favorites_only: bool | None = None,
54
+ agent_model_id: AgentModelId | str | None = None,
55
+ response_shape: QueryResponseShape | None = None,
56
+ include_data: bool | None = None,
57
+ include_data_url: bool | None = None,
58
+ include_developer_trace: bool | None = None,
59
+ resume_from: QueryAttemptReference | dict[str, Any] | None = None,
60
+ fork_from: QueryForkReference | dict[str, Any] | None = None,
61
+ stream: bool | None = None,
62
+ ) -> dict[str, Any]:
63
+ request_body: dict[str, Any] = {
64
+ "query": query,
65
+ "tools": tools,
66
+ }
67
+ if stream is not None:
68
+ request_body["stream"] = stream
69
+ if resume_from is not None:
70
+ request_body["resumeFrom"] = (
71
+ resume_from.model_dump(by_alias=True, exclude_none=True)
72
+ if isinstance(resume_from, QueryAttemptReference)
73
+ else resume_from
74
+ )
75
+ if fork_from is not None:
76
+ request_body["forkFrom"] = (
77
+ fork_from.model_dump(by_alias=True, exclude_none=True)
78
+ if isinstance(fork_from, QueryForkReference)
79
+ else fork_from
80
+ )
81
+ if favorites_only is not None:
82
+ request_body["favoritesOnly"] = favorites_only
83
+ if agent_model_id is not None:
84
+ request_body["agentModelId"] = agent_model_id
85
+ if response_shape is not None:
86
+ request_body["responseShape"] = response_shape
87
+ if include_data is not None:
88
+ request_body["includeData"] = include_data
89
+ if include_data_url is not None:
90
+ request_body["includeDataUrl"] = include_data_url
91
+ if include_developer_trace is not None:
92
+ request_body["includeDeveloperTrace"] = include_developer_trace
93
+ return request_body
94
+
46
95
  @staticmethod
47
96
  def _merge_developer_trace(
48
97
  first: QueryDeveloperTrace | None,
@@ -278,6 +327,71 @@ class Query:
278
327
 
279
328
  raise ContextError("Streaming query ended before done event")
280
329
 
330
+ async def start(
331
+ self,
332
+ query: str,
333
+ tools: list[str] | None = None,
334
+ favorites_only: bool | None = None,
335
+ agent_model_id: AgentModelId | str | None = None,
336
+ response_shape: QueryResponseShape | None = None,
337
+ include_data: bool | None = None,
338
+ include_data_url: bool | None = None,
339
+ include_developer_trace: bool | None = None,
340
+ resume_from: QueryAttemptReference | dict[str, Any] | None = None,
341
+ fork_from: QueryForkReference | dict[str, Any] | None = None,
342
+ idempotency_key: str | None = None,
343
+ ) -> QueryJobStartResult:
344
+ """Start a durable async query job and return its job id immediately."""
345
+ request_body = self._build_request_body(
346
+ query=query,
347
+ tools=tools,
348
+ favorites_only=favorites_only,
349
+ agent_model_id=agent_model_id,
350
+ response_shape=response_shape,
351
+ include_data=include_data,
352
+ include_data_url=include_data_url,
353
+ include_developer_trace=include_developer_trace,
354
+ resume_from=resume_from,
355
+ fork_from=fork_from,
356
+ stream=False,
357
+ )
358
+ response = await self._client.fetch(
359
+ "/api/v1/query/jobs",
360
+ method="POST",
361
+ json_body=request_body,
362
+ extra_headers=(
363
+ {"Idempotency-Key": idempotency_key}
364
+ if idempotency_key
365
+ else None
366
+ ),
367
+ )
368
+ return QueryJobStartResult.model_validate(response)
369
+
370
+ async def get_status(self, job_id: str) -> QueryJobStatusResult:
371
+ """Fetch the current status for a durable async query job."""
372
+ response = await self._client.fetch(f"/api/v1/query/jobs/{job_id}")
373
+ return QueryJobStatusResult.model_validate(response)
374
+
375
+ async def poll(
376
+ self,
377
+ job_id: str,
378
+ interval_ms: int = 2000,
379
+ timeout_ms: int = 15 * 60_000,
380
+ ) -> QueryJobStatusResult:
381
+ """Poll a durable async query job until completion or failure."""
382
+ loop = asyncio.get_running_loop()
383
+ deadline = loop.time() + timeout_ms / 1000
384
+
385
+ while loop.time() <= deadline:
386
+ status = await self.get_status(job_id)
387
+ if status.status == "completed":
388
+ return status
389
+ if status.status == "failed":
390
+ raise ContextError(status.error or "Context query job failed")
391
+ await asyncio.sleep(interval_ms / 1000)
392
+
393
+ raise ContextError(f"Context query job polling timed out after {timeout_ms}ms")
394
+
281
395
  async def stream(
282
396
  self,
283
397
  query: str,
@@ -6,7 +6,7 @@ This module contains all Pydantic models and type definitions used by the client
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Any, Literal, Union
9
+ from typing import Annotated, Any, Literal, Union
10
10
 
11
11
  from pydantic import BaseModel, Field
12
12
 
@@ -46,12 +46,9 @@ SuggestedPromptSource = Literal["contributor", "platform", "sdk"]
46
46
  DEFAULT_AGENT_MODEL_ID = "kimi-k2.6-model"
47
47
  AGENT_MODEL_IDS = (
48
48
  "kimi-k2.6-model",
49
+ "glm-5.2-model",
49
50
  "deepseek-v4-pro-model",
50
51
  "deepseek-v4-flash-model",
51
- "gemini-3.5-flash-model",
52
- "gemini-flash-model",
53
- "gemini-lite-model",
54
- "minimax-m3-model",
55
52
  "qwen-3.7-plus-model",
56
53
  "qwen-3.7-max-model",
57
54
  "gpt-5.5-model",
@@ -59,12 +56,9 @@ AGENT_MODEL_IDS = (
59
56
  )
60
57
  AgentModelId = Literal[
61
58
  "kimi-k2.6-model",
59
+ "glm-5.2-model",
62
60
  "deepseek-v4-pro-model",
63
61
  "deepseek-v4-flash-model",
64
- "gemini-3.5-flash-model",
65
- "gemini-flash-model",
66
- "gemini-lite-model",
67
- "minimax-m3-model",
68
62
  "qwen-3.7-plus-model",
69
63
  "qwen-3.7-max-model",
70
64
  "gpt-5.5-model",
@@ -745,21 +739,21 @@ class QueryOptions(BaseModel):
745
739
  description=(
746
740
  "Structured response mode. Defaults to `answer_with_evidence` on the "
747
741
  "server when omitted. The runtime always produces a grounded result "
748
- "(raw data + computed artifacts + provenance); this controls whether "
749
- "a prose synthesis layer is added on top. "
742
+ "(bounded evidence + computed artifacts + full-data references); "
743
+ "this controls whether a prose synthesis layer is added on top. "
750
744
  "`answer_with_evidence` returns prose plus the structured grounding "
751
745
  "(chat parity); `evidence_only` returns grounding only with no prose "
752
- "(the agent-harness shape, with raw data + computed_artifacts + "
753
- "grounding by default)."
746
+ "(the agent-harness shape, with bounded evidence, computed_artifacts, "
747
+ "and data_url/canonical_data_ref references)."
754
748
  ),
755
749
  )
756
750
  include_data: bool | None = Field(
757
751
  default=None,
758
752
  alias="includeData",
759
753
  description=(
760
- "Include raw execution data inline. Defaults to true for "
761
- "response_shape `evidence_only` (its primary payload is the raw "
762
- "fetched data); false otherwise. Set explicitly to override."
754
+ "Include bounded execution data inline. Defaults to false for every "
755
+ "response_shape. Large payloads are returned as a preview object "
756
+ "with fullData.dataUrl/canonicalDataRef instead of unbounded raw rows."
763
757
  ),
764
758
  )
765
759
  include_data_url: bool | None = Field(
@@ -935,7 +929,31 @@ class QueryChartArtifact(BaseModel):
935
929
  title: str | None = None
936
930
 
937
931
 
938
- QueryComputedArtifact = QueryChartArtifact
932
+ class QueryImageArtifact(BaseModel):
933
+ """Rendered image artifact (e.g. a server-rendered chart PNG).
934
+
935
+ Emitted by the code interpreter alongside or instead of the structured
936
+ ``chart`` spec. ``url`` points at a hosted, already-rendered image so
937
+ consumers that cannot render a chart spec (image-first surfaces such as
938
+ social posting) can attach it directly.
939
+ """
940
+
941
+ kind: Literal["image"]
942
+ url: str
943
+ alt: str | None = None
944
+ title: str | None = None
945
+ content_hash: str | None = Field(default=None, alias="contentHash")
946
+ bytes: int | None = None
947
+ width: int | None = None
948
+ height: int | None = None
949
+
950
+ model_config = {"populate_by_name": True}
951
+
952
+
953
+ QueryComputedArtifact = Annotated[
954
+ Union[QueryChartArtifact, QueryImageArtifact],
955
+ Field(discriminator="kind"),
956
+ ]
939
957
 
940
958
 
941
959
  class QueryToolCallFailureSample(BaseModel):
@@ -1703,7 +1721,7 @@ class QueryResult(BaseModel):
1703
1721
  tools_used: Tools that were used to answer the query
1704
1722
  cost: Cost breakdown
1705
1723
  duration_ms: Total duration in milliseconds
1706
- data: Raw execution data (default for evidence_only; else when include_data is enabled)
1724
+ data: Bounded execution data when include_data is enabled
1707
1725
  data_url: Optional blob URL for execution data (when include_data_url is enabled)
1708
1726
  computed_artifacts: Optional chart artifacts emitted by code interpreter
1709
1727
  developer_trace: Optional machine-readable Developer Mode trace
@@ -1721,9 +1739,9 @@ class QueryResult(BaseModel):
1721
1739
  data: Any | None = Field(
1722
1740
  default=None,
1723
1741
  description=(
1724
- "Raw execution data from tools. Returned by default for "
1725
- "response_shape `evidence_only` (its primary payload); otherwise "
1726
- "only when include_data is true."
1742
+ "Bounded execution data from tools. Returned only when include_data "
1743
+ "is true. Small payloads may be direct data; large payloads are a "
1744
+ "truncation object with preview and fullData references."
1727
1745
  ),
1728
1746
  )
1729
1747
  data_url: str | None = Field(
@@ -1892,6 +1910,40 @@ class QueryApiSuccessResponse(BaseModel):
1892
1910
  model_config = {"populate_by_name": True}
1893
1911
 
1894
1912
 
1913
+ QueryJobStatus = Literal["queued", "running", "completed", "failed"]
1914
+
1915
+
1916
+ class QueryJobStartResult(BaseModel):
1917
+ """Response from starting a durable async query job."""
1918
+
1919
+ status: QueryJobStatus
1920
+ job_id: str = Field(..., alias="jobId")
1921
+ polling_tool: str | None = Field(default=None, alias="pollingTool")
1922
+ message: str | None = None
1923
+ progress: Any | None = None
1924
+ query_session: Any | None = Field(default=None, alias="querySession")
1925
+ created_at: str = Field(..., alias="createdAt")
1926
+ updated_at: str = Field(..., alias="updatedAt")
1927
+
1928
+ model_config = {"populate_by_name": True}
1929
+
1930
+
1931
+ class QueryJobStatusResult(BaseModel):
1932
+ """Current status for a durable async query job."""
1933
+
1934
+ status: QueryJobStatus
1935
+ job_id: str = Field(..., alias="jobId")
1936
+ progress: Any | None = None
1937
+ query_session: Any | None = Field(default=None, alias="querySession")
1938
+ result: QueryResult | None = None
1939
+ error: str | None = None
1940
+ created_at: str = Field(..., alias="createdAt")
1941
+ updated_at: str = Field(..., alias="updatedAt")
1942
+ completed_at: str | None = Field(default=None, alias="completedAt")
1943
+
1944
+ model_config = {"populate_by_name": True}
1945
+
1946
+
1895
1947
  class QueryStreamToolStatusEvent(BaseModel):
1896
1948
  """Emitted when a tool starts or changes execution status."""
1897
1949
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ctxprotocol"
7
- version = "0.17.0"
7
+ version = "0.19.0"
8
8
  description = "Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -897,6 +897,41 @@ class TestQueryRun:
897
897
  assert mixed_axis.spec.series[1].y_axis == "right"
898
898
  assert mixed_axis.spec.series[1].satisfies == "volume"
899
899
 
900
+ async def test_parses_rendered_image_artifact(self) -> None:
901
+ """Rendered image artifacts (kind: image) deserialize without error."""
902
+ client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
903
+ response = {
904
+ **MOCK_SUCCESS_RESPONSE,
905
+ "computedArtifacts": [
906
+ {
907
+ "kind": "image",
908
+ "url": "https://blob.example.com/charts/abc123.png",
909
+ "alt": "Sector ETF YTD normalized performance chart",
910
+ "title": "Sector ETF YTD Performance",
911
+ "contentHash": "abc123",
912
+ "bytes": 320749,
913
+ "width": 1926,
914
+ "height": 1030,
915
+ },
916
+ ],
917
+ }
918
+ with patch.object(
919
+ client, "fetch_stream", new_callable=AsyncMock
920
+ ) as mock_stream:
921
+ mock_stream.return_value = _make_done_stream_response(response)
922
+ result = await client.query.run("Render a chart image")
923
+
924
+ assert result.computed_artifacts is not None
925
+ assert len(result.computed_artifacts) == 1
926
+ image = result.computed_artifacts[0]
927
+ assert image.kind == "image"
928
+ assert image.url == "https://blob.example.com/charts/abc123.png"
929
+ assert image.title == "Sector ETF YTD Performance"
930
+ assert image.content_hash == "abc123"
931
+ assert image.bytes == 320749
932
+ assert image.width == 1926
933
+ assert image.height == 1030
934
+
900
935
  async def test_parses_shared_ungrounded_capability_miss_fixture(self) -> None:
901
936
  """Ungrounded runtime outcomes deserialize as capability_miss."""
902
937
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
@@ -1466,3 +1501,104 @@ class TestQueryStream:
1466
1501
  events.append(event)
1467
1502
 
1468
1503
  assert len(events) == 1
1504
+
1505
+
1506
+ class TestQueryJobs:
1507
+ """Tests for durable async query job helpers."""
1508
+
1509
+ async def test_start_creates_query_job(self) -> None:
1510
+ client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1511
+ payload = {
1512
+ "status": "running",
1513
+ "jobId": "11111111-1111-4111-8111-111111111111",
1514
+ "pollingTool": "context_query_status",
1515
+ "message": "running",
1516
+ "progress": None,
1517
+ "querySession": None,
1518
+ "createdAt": "2026-06-14T00:00:00.000Z",
1519
+ "updatedAt": "2026-06-14T00:00:00.000Z",
1520
+ }
1521
+
1522
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1523
+ mock_fetch.return_value = payload
1524
+ job = await client.query.start(
1525
+ query="long query",
1526
+ response_shape="evidence_only",
1527
+ include_data_url=True,
1528
+ idempotency_key="21118fda-33be-4d66-8df5-0e50b3371f54",
1529
+ )
1530
+
1531
+ assert job.job_id == "11111111-1111-4111-8111-111111111111"
1532
+ mock_fetch.assert_called_once_with(
1533
+ "/api/v1/query/jobs",
1534
+ method="POST",
1535
+ json_body={
1536
+ "query": "long query",
1537
+ "tools": None,
1538
+ "stream": False,
1539
+ "responseShape": "evidence_only",
1540
+ "includeDataUrl": True,
1541
+ },
1542
+ extra_headers={
1543
+ "Idempotency-Key": "21118fda-33be-4d66-8df5-0e50b3371f54",
1544
+ },
1545
+ )
1546
+
1547
+ async def test_get_status_fetches_query_job(self) -> None:
1548
+ client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1549
+ payload = {
1550
+ "status": "completed",
1551
+ "jobId": "11111111-1111-4111-8111-111111111111",
1552
+ "progress": None,
1553
+ "querySession": None,
1554
+ "result": MOCK_SUCCESS_RESPONSE,
1555
+ "error": None,
1556
+ "createdAt": "2026-06-14T00:00:00.000Z",
1557
+ "updatedAt": "2026-06-14T00:01:00.000Z",
1558
+ "completedAt": "2026-06-14T00:01:00.000Z",
1559
+ }
1560
+
1561
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1562
+ mock_fetch.return_value = payload
1563
+ status = await client.query.get_status(
1564
+ "11111111-1111-4111-8111-111111111111"
1565
+ )
1566
+
1567
+ assert status.status == "completed"
1568
+ assert status.result is not None
1569
+ assert status.result.response == MOCK_SUCCESS_RESPONSE["response"]
1570
+ mock_fetch.assert_called_once_with(
1571
+ "/api/v1/query/jobs/11111111-1111-4111-8111-111111111111"
1572
+ )
1573
+
1574
+ async def test_poll_waits_until_query_job_completes(self) -> None:
1575
+ client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1576
+ running_payload = {
1577
+ "status": "running",
1578
+ "jobId": "11111111-1111-4111-8111-111111111111",
1579
+ "progress": None,
1580
+ "querySession": None,
1581
+ "result": None,
1582
+ "error": None,
1583
+ "createdAt": "2026-06-14T00:00:00.000Z",
1584
+ "updatedAt": "2026-06-14T00:00:30.000Z",
1585
+ "completedAt": None,
1586
+ }
1587
+ completed_payload = {
1588
+ **running_payload,
1589
+ "status": "completed",
1590
+ "result": MOCK_SUCCESS_RESPONSE,
1591
+ "updatedAt": "2026-06-14T00:01:00.000Z",
1592
+ "completedAt": "2026-06-14T00:01:00.000Z",
1593
+ }
1594
+
1595
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1596
+ mock_fetch.side_effect = [running_payload, completed_payload]
1597
+ status = await client.query.poll(
1598
+ "11111111-1111-4111-8111-111111111111",
1599
+ interval_ms=1,
1600
+ timeout_ms=1000,
1601
+ )
1602
+
1603
+ assert status.status == "completed"
1604
+ assert mock_fetch.await_count == 2
@@ -205,7 +205,7 @@ wheels = [
205
205
 
206
206
  [[package]]
207
207
  name = "ctxprotocol"
208
- version = "0.17.0"
208
+ version = "0.19.0"
209
209
  source = { editable = "." }
210
210
  dependencies = [
211
211
  { name = "cryptography" },
File without changes