unique-sdk 2026.32.0.dev2__tar.gz → 2026.32.0.dev4__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 (91) hide show
  1. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/PKG-INFO +1 -1
  2. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/pyproject.toml +1 -1
  3. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_web_search.py +8 -0
  4. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/cli.py +28 -0
  5. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/mcp.py +107 -8
  6. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/web_search.py +10 -0
  7. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/README.md +0 -0
  8. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/__init__.py +0 -0
  9. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_api_requestor.py +0 -0
  10. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_api_resource.py +0 -0
  11. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_api_version.py +0 -0
  12. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_error.py +0 -0
  13. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_http_client.py +0 -0
  14. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_list_object.py +0 -0
  15. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_object_classes.py +0 -0
  16. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_request_options.py +0 -0
  17. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_unique_object.py +0 -0
  18. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_unique_ql.py +0 -0
  19. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_unique_response.py +0 -0
  20. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_util.py +0 -0
  21. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_version.py +0 -0
  22. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/_webhook.py +0 -0
  23. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/__init__.py +0 -0
  24. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_acronyms.py +0 -0
  25. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_agentic_table.py +0 -0
  26. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_analytics_order.py +0 -0
  27. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_benchmarking.py +0 -0
  28. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_briefing.py +0 -0
  29. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_chat_completion.py +0 -0
  30. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_content.py +0 -0
  31. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  32. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_elicitation.py +0 -0
  33. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_embedding.py +0 -0
  34. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_event.py +0 -0
  35. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_folder.py +0 -0
  36. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_group.py +0 -0
  37. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_integrated.py +0 -0
  38. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_llm_models.py +0 -0
  39. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_mcp.py +0 -0
  40. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_message.py +0 -0
  41. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_message_assessment.py +0 -0
  42. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_message_execution.py +0 -0
  43. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_message_log.py +0 -0
  44. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_message_tool.py +0 -0
  45. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_module.py +0 -0
  46. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  47. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_search.py +0 -0
  48. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_search_string.py +0 -0
  49. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  50. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_space.py +0 -0
  51. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/api_resources/_user.py +0 -0
  52. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/__init__.py +0 -0
  53. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/__main__.py +0 -0
  54. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/__init__.py +0 -0
  55. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  56. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/agentic_table.py +0 -0
  57. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/browser.py +0 -0
  58. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/cite_file.py +0 -0
  59. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  60. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/elicitation.py +0 -0
  61. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/files.py +0 -0
  62. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/folders.py +0 -0
  63. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/navigation.py +0 -0
  64. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/read.py +0 -0
  65. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  66. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/search.py +0 -0
  67. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/subagent.py +0 -0
  68. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/commands/web_search_config.py +0 -0
  69. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/config.py +0 -0
  70. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/formatting.py +0 -0
  71. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/identity.py +0 -0
  72. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/metadata_filter.py +0 -0
  73. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/shell.py +0 -0
  74. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  75. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
  76. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  77. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  78. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  79. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  80. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  81. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  82. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  83. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  84. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/cli/state.py +0 -0
  85. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/analytics_order_run.py +0 -0
  86. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/benchmarking_run.py +0 -0
  87. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/chat_history.py +0 -0
  88. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/chat_in_space.py +0 -0
  89. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/file_io.py +0 -0
  90. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/sources.py +0 -0
  91. {unique_sdk-2026.32.0.dev2 → unique_sdk-2026.32.0.dev4}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.32.0.dev2
3
+ Version: 2026.32.0.dev4
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.32.0.dev2"
3
+ version = "2026.32.0.dev4"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -58,6 +58,10 @@ class WebSearch(APIResource["WebSearch"]):
58
58
  searchEngineConfig: NotRequired[dict[str, Any] | None]
59
59
  # Used only when includeContent=True and the engine needs scraping.
60
60
  crawlerConfig: NotRequired[dict[str, Any] | None]
61
+ # Optional: when set, the space's Web Search toggle is enforced
62
+ # server-side for this call. Omit for callers not acting on
63
+ # behalf of a specific chat (unchanged legacy behavior).
64
+ chatId: NotRequired[str | None]
61
65
 
62
66
  # Response envelope.
63
67
  engine: str
@@ -130,6 +134,10 @@ class WebCrawl(APIResource["WebCrawl"]):
130
134
  urls: list[str]
131
135
  parallel: NotRequired[int | None]
132
136
  crawlerConfig: NotRequired[dict[str, Any] | None]
137
+ # Optional: when set, the space's Web Search toggle is enforced
138
+ # server-side for this call. Omit for callers not acting on
139
+ # behalf of a specific chat (unchanged legacy behavior).
140
+ chatId: NotRequired[str | None]
133
141
 
134
142
  # Response envelope.
135
143
  crawler: str
@@ -2145,6 +2145,18 @@ Examples:
2145
2145
  is_flag=True,
2146
2146
  help="Output results as JSON (suitable for piping into web-search crawl --stdin).",
2147
2147
  )
2148
+ @click.option(
2149
+ "--chat-id",
2150
+ "chat_id",
2151
+ default=None,
2152
+ envvar="UNIQUE_CHAT_ID",
2153
+ help=(
2154
+ "Chat id this call is made on behalf of. Defaults to $UNIQUE_CHAT_ID "
2155
+ "(auto-set in Conduct sandboxes). When set, the space's Web Search "
2156
+ "toggle is enforced server-side and the call is rejected if Web "
2157
+ "Search is disabled for that space."
2158
+ ),
2159
+ )
2148
2160
  @click.pass_context
2149
2161
  def web_search_search_cmd(
2150
2162
  ctx: click.Context,
@@ -2155,6 +2167,7 @@ def web_search_search_cmd(
2155
2167
  crawler_config_raw: str | None,
2156
2168
  config_path: str | None,
2157
2169
  output_json: bool,
2170
+ chat_id: str | None,
2158
2171
  ) -> None:
2159
2172
  """Search the web via the Unique public API."""
2160
2173
  resolved_config = _resolve_web_search_config_path(ctx, config_path)
@@ -2167,6 +2180,7 @@ def web_search_search_cmd(
2167
2180
  crawler_config_raw=crawler_config_raw,
2168
2181
  output_json=output_json,
2169
2182
  config_path=resolved_config,
2183
+ chat_id=chat_id,
2170
2184
  )
2171
2185
  _emit_web_search(ctx, output)
2172
2186
 
@@ -2222,6 +2236,18 @@ Examples:
2222
2236
  is_flag=True,
2223
2237
  help="Output results as JSON.",
2224
2238
  )
2239
+ @click.option(
2240
+ "--chat-id",
2241
+ "chat_id",
2242
+ default=None,
2243
+ envvar="UNIQUE_CHAT_ID",
2244
+ help=(
2245
+ "Chat id this call is made on behalf of. Defaults to $UNIQUE_CHAT_ID "
2246
+ "(auto-set in Conduct sandboxes). When set, the space's Web Search "
2247
+ "toggle is enforced server-side and the call is rejected if Web "
2248
+ "Search is disabled for that space."
2249
+ ),
2250
+ )
2225
2251
  @click.pass_context
2226
2252
  def web_search_crawl_cmd(
2227
2253
  ctx: click.Context,
@@ -2231,6 +2257,7 @@ def web_search_crawl_cmd(
2231
2257
  crawler_config_raw: str | None,
2232
2258
  config_path: str | None,
2233
2259
  output_json: bool,
2260
+ chat_id: str | None,
2234
2261
  ) -> None:
2235
2262
  """Crawl URLs via the Unique public API."""
2236
2263
  url_list: list[str] = list(urls)
@@ -2248,6 +2275,7 @@ def web_search_crawl_cmd(
2248
2275
  crawler_config_raw=crawler_config_raw,
2249
2276
  output_json=output_json,
2250
2277
  config_path=resolved_config,
2278
+ chat_id=chat_id,
2251
2279
  )
2252
2280
  _emit_web_search(ctx, output)
2253
2281
 
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import logging
8
+ import os
7
9
  import re
8
10
  import sys
9
11
  from pathlib import Path
@@ -44,6 +46,14 @@ _MCP_OUTPUT_TEXT_CHAR_LIMIT = 200_000
44
46
  # the result carries no recognizable title.
45
47
  _MCP_REFS_LOG_RELATIVE_PATH = Path(".unique") / "mcp-refs.jsonl"
46
48
  _MCP_REFS_LOCK_FILENAME = "mcp-refs.lock"
49
+ # Persistent per-chat seed for ``[mcpsourceN]`` numbering. The SI runner wipes
50
+ # ``mcp-refs.jsonl`` between turns, so manifest-derived numbering restarts at 1
51
+ # every turn; this sibling file records the highest source number ever assigned
52
+ # in the chat so numbering stays monotonic across turns (UN-23199). Body is
53
+ # ``{"maxSourceNumber": <int>}``; the monorepo runner preserves this file across
54
+ # its per-turn reset. Absent/unreadable → seed 0 → identical to pre-seed
55
+ # behavior (forward/backward compatible).
56
+ _MCP_REFS_SEED_FILENAME = "mcp-refs-seed.json"
47
57
  _MCP_SNIPPET_CHAR_LIMIT = 300
48
58
  # Writer-side cap on the per-item ``text`` recorded in the refs manifest — the
49
59
  # cited item's underlying text, consumed by the runner's hallucination check to
@@ -533,12 +543,75 @@ def _next_mcp_source_number(entries: list[dict[str, Any]]) -> int:
533
543
  return max(numbers, default=0) + 1
534
544
 
535
545
 
546
+ def _read_mcp_refs_seed(seed_path: Path) -> int:
547
+ """Read the persisted chat-wide ``maxSourceNumber`` from the seed file.
548
+
549
+ Best-effort: returns ``0`` when the file is absent, unreadable, or malformed
550
+ (never raises), so a missing seed reproduces the pre-seed numbering exactly.
551
+ """
552
+ try:
553
+ raw = seed_path.read_text(encoding="utf-8")
554
+ value = json.loads(raw).get("maxSourceNumber")
555
+ if isinstance(value, int) and not isinstance(value, bool):
556
+ return value
557
+ except (OSError, ValueError, TypeError, AttributeError):
558
+ return 0
559
+ return 0
560
+
561
+
562
+ def _write_mcp_refs_seed(seed_path: Path, value: int) -> None:
563
+ """Persist the chat-wide ``maxSourceNumber`` seed (best-effort, never raises).
564
+
565
+ Only writes when ``value`` exceeds the currently persisted seed, so numbers
566
+ never regress across turns.
567
+
568
+ The temp file is opened with ``O_NOFOLLOW`` so a symlink pre-planted at the
569
+ predictable temp path cannot redirect the write outside the refs directory
570
+ (defense in depth on top of the per-user workspace sandbox). ``os.replace``
571
+ operates on the directory entry and does not follow a symlink at the target.
572
+ ``O_NOFOLLOW`` is POSIX-only; on platforms lacking it (e.g. Windows) it
573
+ degrades to ``0`` via ``getattr`` rather than raising ``AttributeError`` —
574
+ which, being outside the ``OSError`` handler, would otherwise bubble out of
575
+ ``_annotate_mcp_results_for_citations`` after entries were written and blank
576
+ the Sources block.
577
+ """
578
+ try:
579
+ if value <= _read_mcp_refs_seed(seed_path):
580
+ return
581
+ seed_path.parent.mkdir(parents=True, exist_ok=True)
582
+ tmp_path = seed_path.with_suffix(seed_path.suffix + ".tmp")
583
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
584
+ fd = os.open(tmp_path, flags, 0o600)
585
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
586
+ handle.write(json.dumps({"maxSourceNumber": value}))
587
+ os.replace(tmp_path, seed_path)
588
+ except OSError as exc:
589
+ _LOGGER.warning("mcp: failed to write refs seed: %s", exc)
590
+
591
+
536
592
  def _item_dedup_key(tool_name: str, item: dict[str, Any]) -> str:
537
- """Dedup by title when present (same item = one chip), else by tool."""
593
+ """Dedup by title when present (same item = one chip), else by tool + a short
594
+ content hash of the item's text.
595
+
596
+ Two distinct title-less fetches through one tool return different bodies, so
597
+ hashing the text keeps them as separate sources instead of collapsing onto
598
+ one number (identical bodies still merge). NOTE: this intentionally weakens
599
+ the search-then-fetch text-upgrade merge for title-less items only — titled
600
+ items still merge by title as before.
601
+
602
+ The text is capped at ``_MCP_REF_TEXT_CHAR_LIMIT`` BEFORE hashing — the same
603
+ cap the manifest stores under ``text``. Without it, the first call (live,
604
+ full-length item text) and a later call rebuilding this key from the
605
+ truncated manifest entry would hash to different values, so an oversized
606
+ title-less result would be re-assigned a duplicate ``[mcpsourceN]`` instead
607
+ of deduping.
608
+ """
538
609
  title = item.get("title")
539
610
  if isinstance(title, str) and title.strip():
540
611
  return f"title:{tool_name}:{title.strip()}"
541
- return f"tool:{tool_name}"
612
+ capped_text = (item.get("text") or "")[:_MCP_REF_TEXT_CHAR_LIMIT]
613
+ text_hash = hashlib.sha256(capped_text.encode("utf-8")).hexdigest()[:12]
614
+ return f"tool:{tool_name}:{text_hash}"
542
615
 
543
616
 
544
617
  def _ref_text(item: dict[str, Any]) -> str | None:
@@ -563,8 +636,11 @@ def _annotate_mcp_results_for_citations(
563
636
  """Assign per-turn ``[mcpsourceN]`` numbers to each retrieved item and append
564
637
  the refs manifest. Returns ``[(sourceNumber, item)]`` for the Sources block.
565
638
 
566
- Items dedup by title across the turn (same item keeps one number), or by
567
- tool for the title-less fallback. Best-effort returns ``[]`` on any failure
639
+ Items dedup by title across the turn (same item keeps one number); a
640
+ title-less item dedups by tool plus a content hash of its (capped) text, so
641
+ two distinct title-less results through one tool get distinct numbers while
642
+ identical bodies still merge (this weakens the search-then-fetch text-upgrade
643
+ merge for title-less items only). Best-effort — returns ``[]`` on any failure
568
644
  (the tool result is unaffected; only the citation Sources block is skipped).
569
645
  """
570
646
  refs_log_path = refs_log_path or (Path.cwd() / _MCP_REFS_LOG_RELATIVE_PATH)
@@ -581,6 +657,14 @@ def _annotate_mcp_results_for_citations(
581
657
  refs_log_path, lock_filename=_MCP_REFS_LOCK_FILENAME
582
658
  ):
583
659
  entries = _read_turn_refs_manifest(refs_log_path)
660
+ # Chat-wide monotonic seed: the runner wipes ``mcp-refs.jsonl``
661
+ # between turns, so manifest-derived numbering restarts at 1 each
662
+ # turn. The seed file (preserved across the runner's per-turn reset)
663
+ # records the highest number ever assigned in the chat so a fresh
664
+ # number exceeds both the current manifest max AND the chat-wide max.
665
+ seed_path = refs_log_path.parent / _MCP_REFS_SEED_FILENAME
666
+ seed = _read_mcp_refs_seed(seed_path)
667
+ highest_assigned = seed
584
668
  numbers_by_key: dict[str, int] = {}
585
669
  for entry in entries:
586
670
  if isinstance(entry.get("sourceNumber"), int):
@@ -598,7 +682,7 @@ def _annotate_mcp_results_for_citations(
598
682
  key = _item_dedup_key(tool_name, item)
599
683
  source_number = numbers_by_key.get(key)
600
684
  if source_number is None:
601
- source_number = _next_mcp_source_number(entries)
685
+ source_number = max(_next_mcp_source_number(entries), seed + 1)
602
686
  manifest_entry = {
603
687
  "sourceNumber": source_number,
604
688
  "toolName": tool_name,
@@ -618,6 +702,7 @@ def _annotate_mcp_results_for_citations(
618
702
  numbers_by_key[key] = source_number
619
703
  entries.append(manifest_entry)
620
704
  entries_by_number[source_number] = manifest_entry
705
+ highest_assigned = max(highest_assigned, source_number)
621
706
  else:
622
707
  # Deduped: a prior call already claimed this source number.
623
708
  # Backfill ``details`` the first call may have lacked (the
@@ -649,6 +734,10 @@ def _annotate_mcp_results_for_citations(
649
734
  _LOGGER.warning(
650
735
  "mcp: failed to backfill refs manifest enrichment: %s", exc
651
736
  )
737
+ # Persist the chat-wide max so next turn's numbering continues above
738
+ # it even after the runner wipes ``mcp-refs.jsonl``.
739
+ if highest_assigned > seed:
740
+ _write_mcp_refs_seed(seed_path, highest_assigned)
652
741
  except (UnsafeRefsLogPathError, OSError) as exc:
653
742
  _LOGGER.warning("mcp: failed to append refs manifest: %s", exc)
654
743
  return []
@@ -658,13 +747,19 @@ def _annotate_mcp_results_for_citations(
658
747
  return annotated
659
748
 
660
749
 
661
- def _citation_sources_block(annotated: list[tuple[int, dict[str, Any]]]) -> str:
750
+ def _citation_sources_block(
751
+ annotated: list[tuple[int, dict[str, Any]]], *, tool_name: str
752
+ ) -> str:
662
753
  """Tell the agent which marker to cite each retrieved item with.
663
754
 
664
755
  Rendered *before* the tool output (leading block, not a trailing footer):
665
756
  the agent harness spills oversized tool results to a file wholesale, and a
666
757
  partial or programmatic read of that file only reliably sees the head — a
667
758
  trailing marker list would be exactly what such reads miss (UN-22309).
759
+
760
+ Each line names the ``tool_name`` alongside the marker so the model has a
761
+ stronger anchor than a bare integer: ``{title} — {tool_name}`` when the item
762
+ carries a title, or ``{tool_name}`` for the title-less fallback.
668
763
  """
669
764
  if not annotated:
670
765
  return ""
@@ -674,7 +769,11 @@ def _citation_sources_block(annotated: list[tuple[int, dict[str, Any]]]) -> str:
674
769
  "will not be referenced in the answer:",
675
770
  ]
676
771
  for source_number, item in annotated:
677
- label = item.get("title") or "this MCP tool result"
772
+ title = item.get("title")
773
+ if isinstance(title, str) and title.strip():
774
+ label = f"{title.strip()} — {tool_name}"
775
+ else:
776
+ label = tool_name
678
777
  lines.append(f" [mcpsource{source_number}] {label}")
679
778
  return "\n".join(lines)
680
779
 
@@ -758,7 +857,7 @@ def record_mcp_citations(
758
857
  reference_mapping=reference_mapping,
759
858
  fallback_text=formatted_text,
760
859
  )
761
- return _citation_sources_block(annotated)
860
+ return _citation_sources_block(annotated, tool_name=tool_name)
762
861
 
763
862
 
764
863
  def _read_payload(
@@ -317,6 +317,7 @@ def cmd_web_search(
317
317
  crawler_config_raw: str | None = None,
318
318
  output_json: bool = False,
319
319
  config_path: str | None = None,
320
+ chat_id: str | None = None,
320
321
  ) -> str:
321
322
  """Run a web search via the public API.
322
323
 
@@ -341,6 +342,8 @@ def cmd_web_search(
341
342
  then ``~/.unique-websearch.json``.
342
343
  output_json: When ``True``, return a JSON envelope instead of a
343
344
  human-friendly table.
345
+ chat_id: Optional chat id this call is made on behalf of. When
346
+ set, the space's Web Search toggle is enforced server-side.
344
347
  """
345
348
  file_overrides = _load_file_overrides(config_path)
346
349
  if isinstance(file_overrides, str):
@@ -368,6 +371,8 @@ def cmd_web_search(
368
371
  params["searchEngineConfig"] = engine_override
369
372
  if crawler_override is not None:
370
373
  params["crawlerConfig"] = crawler_override
374
+ if chat_id:
375
+ params["chatId"] = chat_id
371
376
 
372
377
  try:
373
378
  resource = unique_sdk.WebSearch.search(
@@ -394,6 +399,7 @@ def cmd_web_crawl(
394
399
  crawler_config_raw: str | None = None,
395
400
  output_json: bool = False,
396
401
  config_path: str | None = None,
402
+ chat_id: str | None = None,
397
403
  ) -> str:
398
404
  """Crawl a list of URLs via the public API.
399
405
 
@@ -410,6 +416,8 @@ def cmd_web_crawl(
410
416
  :func:`cmd_web_search` for resolution rules.
411
417
  output_json: When ``True``, return a JSON envelope instead of a
412
418
  human-friendly table.
419
+ chat_id: Optional chat id this call is made on behalf of. When
420
+ set, the space's Web Search toggle is enforced server-side.
413
421
  """
414
422
  if not urls:
415
423
  return f"{WEB_CRAWL_ERROR_PREFIX} no URLs provided. Pass URLs as arguments or use --stdin."
@@ -432,6 +440,8 @@ def cmd_web_crawl(
432
440
  params: dict[str, Any] = {"urls": list(urls), "parallel": parallel}
433
441
  if crawler_override is not None:
434
442
  params["crawlerConfig"] = crawler_override
443
+ if chat_id:
444
+ params["chatId"] = chat_id
435
445
 
436
446
  try:
437
447
  resource = unique_sdk.WebCrawl.crawl(