unique-sdk 2026.32.0.dev8__tar.gz → 2026.32.0.dev10__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.dev8 → unique_sdk-2026.32.0.dev10}/PKG-INFO +1 -1
  2. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/pyproject.toml +1 -1
  3. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/__init__.py +0 -3
  4. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/cli.py +25 -107
  5. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/elicitation.py +160 -25
  6. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/shell.py +9 -9
  7. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +25 -18
  8. unique_sdk-2026.32.0.dev8/unique_sdk/api_resources/_dynamic_frontend.py +0 -101
  9. unique_sdk-2026.32.0.dev8/unique_sdk/cli/commands/dynamic_frontend.py +0 -155
  10. unique_sdk-2026.32.0.dev8/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -151
  11. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/README.md +0 -0
  12. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_api_requestor.py +0 -0
  13. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_api_resource.py +0 -0
  14. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_api_version.py +0 -0
  15. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_error.py +0 -0
  16. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_http_client.py +0 -0
  17. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_list_object.py +0 -0
  18. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_object_classes.py +0 -0
  19. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_request_options.py +0 -0
  20. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_unique_object.py +0 -0
  21. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_unique_ql.py +0 -0
  22. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_unique_response.py +0 -0
  23. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_util.py +0 -0
  24. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_version.py +0 -0
  25. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/_webhook.py +0 -0
  26. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/__init__.py +0 -0
  27. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_acronyms.py +0 -0
  28. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_agentic_table.py +0 -0
  29. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_analytics_order.py +0 -0
  30. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_benchmarking.py +0 -0
  31. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_briefing.py +0 -0
  32. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_chat_completion.py +0 -0
  33. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_content.py +0 -0
  34. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_elicitation.py +0 -0
  35. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_embedding.py +0 -0
  36. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_event.py +0 -0
  37. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_folder.py +0 -0
  38. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_group.py +0 -0
  39. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_integrated.py +0 -0
  40. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_llm_models.py +0 -0
  41. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_mcp.py +0 -0
  42. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_message.py +0 -0
  43. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_message_assessment.py +0 -0
  44. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_message_execution.py +0 -0
  45. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_message_log.py +0 -0
  46. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_message_tool.py +0 -0
  47. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_module.py +0 -0
  48. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  49. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_search.py +0 -0
  50. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_search_string.py +0 -0
  51. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  52. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_space.py +0 -0
  53. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_user.py +0 -0
  54. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/api_resources/_web_search.py +0 -0
  55. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/__init__.py +0 -0
  56. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/__main__.py +0 -0
  57. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/__init__.py +0 -0
  58. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  59. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/agentic_table.py +0 -0
  60. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/browser.py +0 -0
  61. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/cite_file.py +0 -0
  62. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/files.py +0 -0
  63. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/folders.py +0 -0
  64. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/mcp.py +0 -0
  65. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/navigation.py +0 -0
  66. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/read.py +0 -0
  67. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  68. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/search.py +0 -0
  69. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/web_search.py +0 -0
  71. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/commands/web_search_config.py +0 -0
  72. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/config.py +0 -0
  73. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/formatting.py +0 -0
  74. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/identity.py +0 -0
  75. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/metadata_filter.py +0 -0
  76. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  77. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  78. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  79. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  80. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  81. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  82. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  83. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  84. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/cli/state.py +0 -0
  85. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/analytics_order_run.py +0 -0
  86. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/benchmarking_run.py +0 -0
  87. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/chat_history.py +0 -0
  88. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/chat_in_space.py +0 -0
  89. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/file_io.py +0 -0
  90. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/unique_sdk/utils/sources.py +0 -0
  91. {unique_sdk-2026.32.0.dev8 → unique_sdk-2026.32.0.dev10}/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.dev8
3
+ Version: 2026.32.0.dev10
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.dev8"
3
+ version = "2026.32.0.dev10"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -77,9 +77,6 @@ from unique_sdk.api_resources._message import Message as Message
77
77
  from unique_sdk.api_resources._integrated import Integrated as Integrated
78
78
  from unique_sdk.api_resources._search import Search as Search
79
79
  from unique_sdk.api_resources._content import Content as Content
80
- from unique_sdk.api_resources._dynamic_frontend import (
81
- DynamicFrontend as DynamicFrontend,
82
- )
83
80
  from unique_sdk.api_resources._search_string import SearchString as SearchString
84
81
  from unique_sdk.api_resources._short_term_memory import (
85
82
  ShortTermMemory as ShortTermMemory,
@@ -31,11 +31,6 @@ from unique_sdk.cli.commands.cite_file import cmd_cite_file
31
31
  from unique_sdk.cli.commands.cite_file import (
32
32
  is_error_output as _is_cite_error_output,
33
33
  )
34
- from unique_sdk.cli.commands.dynamic_frontend import (
35
- cmd_dynamic_frontend_delete,
36
- cmd_dynamic_frontend_deploy,
37
- cmd_dynamic_frontend_list,
38
- )
39
34
  from unique_sdk.cli.commands.elicitation import (
40
35
  DEFAULT_WAIT_TIMEOUT_SECONDS,
41
36
  cmd_elicit_ask,
@@ -97,8 +92,6 @@ from unique_sdk.cli.identity import TurnIdentityError, resolve_message_id
97
92
  from unique_sdk.cli.shell import UniqueShell
98
93
  from unique_sdk.cli.state import ShellState
99
94
 
100
- _DYNAMIC_FRONTEND_ERROR_PREFIX = "dynamic-frontend "
101
-
102
95
 
103
96
  def _resolve_cli_message_id(
104
97
  ctx: click.Context,
@@ -174,7 +167,6 @@ Examples:
174
167
  unique-cli subagent Legal "Review" Invoke a connected space/subagent
175
168
  unique-cli web-search search "x" Search the web via the public API
176
169
  unique-cli web-search crawl URL Crawl a URL via the public API
177
- unique-cli dynamic-frontend list List manageable Dynamic Frontend spaces
178
170
  unique-cli browser get-dom Read the user's live Chrome tab (a11y tree)
179
171
  unique-cli agentic-table get-sheet mt_abc123 Show a magic-table sheet summary
180
172
  """
@@ -582,101 +574,6 @@ def read_cmd(
582
574
  emit(output, is_error=_is_read_error_output)
583
575
 
584
576
 
585
- @main.group(name="dynamic-frontend")
586
- def dynamic_frontend() -> None:
587
- """Deploy, list, and delete Dynamic Frontend spaces."""
588
-
589
-
590
- @dynamic_frontend.command(name="deploy")
591
- @click.option(
592
- "--file",
593
- "file_path",
594
- default=None,
595
- type=click.Path(exists=True),
596
- help="Path to an upload-ready Dynamic Frontend ZIP bundle.",
597
- )
598
- @click.option(
599
- "--content-id",
600
- default=None,
601
- help="Existing Knowledge Base content id for the ZIP bundle.",
602
- )
603
- @click.option(
604
- "--name",
605
- default=None,
606
- help="Space display name. Required when creating; optional rename when updating.",
607
- )
608
- @click.option(
609
- "--space-id", default=None, help="Existing Dynamic Frontend space id to update."
610
- )
611
- @click.option(
612
- "--json", "output_json", is_flag=True, default=False, help="Print raw JSON."
613
- )
614
- @click.pass_context
615
- def dynamic_frontend_deploy(
616
- ctx: click.Context,
617
- file_path: str | None,
618
- content_id: str | None,
619
- name: str | None,
620
- space_id: str | None,
621
- output_json: bool,
622
- ) -> None:
623
- """Create or update a Dynamic Frontend space.
624
-
625
- \b
626
- Examples:
627
- unique-cli dynamic-frontend deploy --file ./app.zip --name "Revenue Dashboard"
628
- unique-cli dynamic-frontend deploy --content-id content_123 --name "Revenue Dashboard"
629
- unique-cli dynamic-frontend deploy --space-id assistant_123 --file ./app.zip
630
- """
631
- output = cmd_dynamic_frontend_deploy(
632
- LazyState.get(ctx),
633
- file=file_path,
634
- content_id=content_id,
635
- name=name,
636
- space_id=space_id,
637
- output_json=output_json,
638
- )
639
- click.echo(output)
640
- if output.startswith(_DYNAMIC_FRONTEND_ERROR_PREFIX):
641
- ctx.exit(1)
642
-
643
-
644
- @dynamic_frontend.command(name="list")
645
- @click.option(
646
- "--json", "output_json", is_flag=True, default=False, help="Print raw JSON."
647
- )
648
- @click.pass_context
649
- def dynamic_frontend_list(ctx: click.Context, output_json: bool) -> None:
650
- """List Dynamic Frontend spaces the current user can manage."""
651
- output = cmd_dynamic_frontend_list(LazyState.get(ctx), output_json=output_json)
652
- click.echo(output)
653
- if output.startswith(_DYNAMIC_FRONTEND_ERROR_PREFIX):
654
- ctx.exit(1)
655
-
656
-
657
- @dynamic_frontend.command(name="delete")
658
- @click.argument("space_id")
659
- @click.option(
660
- "--json", "output_json", is_flag=True, default=False, help="Print raw JSON."
661
- )
662
- @click.pass_context
663
- def dynamic_frontend_delete(
664
- ctx: click.Context, space_id: str, output_json: bool
665
- ) -> None:
666
- """Delete a deployed Dynamic Frontend space by its space id.
667
-
668
- \b
669
- Example:
670
- unique-cli dynamic-frontend delete assistant_123
671
- """
672
- output = cmd_dynamic_frontend_delete(
673
- LazyState.get(ctx), space_id, output_json=output_json
674
- )
675
- click.echo(output)
676
- if output.startswith(_DYNAMIC_FRONTEND_ERROR_PREFIX):
677
- ctx.exit(1)
678
-
679
-
680
577
  @main.command()
681
578
  @click.argument("name_or_id")
682
579
  @click.pass_context
@@ -1257,9 +1154,24 @@ def elicit() -> None:
1257
1154
  default=DEFAULT_WAIT_TIMEOUT_SECONDS,
1258
1155
  show_default=True,
1259
1156
  help=(
1260
- "Max seconds to block waiting for the user's response. This also "
1261
- "sets when the elicitation expires, so the request expires exactly "
1262
- "when we stop waiting and the chat UI can offer a way to continue."
1157
+ "Max seconds to block waiting for the user's response. Without "
1158
+ "--expires-in, this also sets when the elicitation expires, so the "
1159
+ "request expires exactly when we stop waiting and the chat UI can "
1160
+ "offer a way to continue."
1161
+ ),
1162
+ )
1163
+ @click.option(
1164
+ "--expires-in",
1165
+ "expires_in_seconds",
1166
+ type=int,
1167
+ default=None,
1168
+ help=(
1169
+ "Seconds before the platform expires the request, decoupled from "
1170
+ "--timeout (the local wait budget). Use this when this process's "
1171
+ "wait budget is shorter than how long a human should realistically "
1172
+ "get to answer (e.g. a harness with its own foreground timeout) -- "
1173
+ "otherwise the request expires under the user before they can "
1174
+ "answer. Defaults to --timeout, exactly matching prior behavior."
1263
1175
  ),
1264
1176
  )
1265
1177
  @click.option(
@@ -1320,6 +1232,7 @@ def elicit_ask(
1320
1232
  chat_id: str | None,
1321
1233
  message_id: str | None,
1322
1234
  timeout: int,
1235
+ expires_in_seconds: int | None,
1323
1236
  poll_interval: float,
1324
1237
  metadata: tuple[str, ...],
1325
1238
  visible: bool = True,
@@ -1332,7 +1245,9 @@ def elicit_ask(
1332
1245
  \b
1333
1246
  Creates a FORM elicitation in the Unique UI with the given MESSAGE
1334
1247
  and blocks until the user responds, the elicitation is declined /
1335
- cancelled / expired, or --timeout is reached.
1248
+ cancelled / expired, or --timeout is reached. Immediately after
1249
+ creation, before polling starts, a single line is written to stderr:
1250
+ "UNIQUE_ELICITATION_CREATED id=<id> expires_at=<iso8601>".
1336
1251
 
1337
1252
  \b
1338
1253
  Examples:
@@ -1340,6 +1255,8 @@ def elicit_ask(
1340
1255
  unique-cli elicit ask "Confirm deletion of /Archive" --timeout 60
1341
1256
  unique-cli elicit ask "Pick a region" \\
1342
1257
  --schema '{"type":"object","properties":{"region":{"type":"string","enum":["EU","US","APAC"]}},"required":["region"]}'
1258
+ unique-cli elicit ask "Long-running approval" \\
1259
+ --expires-in 7200 --timeout 300
1343
1260
  """
1344
1261
  parsed_metadata: list[tuple[str, str]] = []
1345
1262
  for kv in metadata:
@@ -1356,6 +1273,7 @@ def elicit_ask(
1356
1273
  "chat_id": chat_id,
1357
1274
  "message_id": _resolve_cli_message_id(ctx, message_id),
1358
1275
  "timeout": timeout,
1276
+ "expires_in_seconds": expires_in_seconds,
1359
1277
  "poll_interval": poll_interval,
1360
1278
  "metadata": parsed_metadata or None,
1361
1279
  "visible": visible,
@@ -13,8 +13,9 @@ from __future__ import annotations
13
13
 
14
14
  import json
15
15
  import os
16
+ import sys
16
17
  import time
17
- from datetime import datetime, timezone
18
+ from datetime import datetime, timedelta, timezone
18
19
  from typing import Any, Literal, cast
19
20
 
20
21
  import unique_sdk
@@ -37,6 +38,23 @@ TERMINAL_STATUSES = {
37
38
  "COMPLETED",
38
39
  }
39
40
 
41
+ # --- Transient-failure retry (UN-23310) ---------------------------------
42
+ #
43
+ # ``cmd_elicit_wait`` used to return on the very first ``APIError`` /
44
+ # ``APIConnectionError`` raised mid-poll, treating a transient 502 the same
45
+ # as a genuine terminal condition. Now that a single ``elicit ask`` call is
46
+ # expected to carry the whole wait unsupervised, that one dropped connection
47
+ # would otherwise cost the entire turn. These constants bound the retry
48
+ # loop's backoff; the overall ``--timeout`` remains the hard outer bound.
49
+ TRANSIENT_RETRY_BASE_SECONDS = 1.0
50
+ TRANSIENT_RETRY_MAX_BACKOFF_SECONDS = 30.0
51
+
52
+ # Prefix for the machine-readable line ``cmd_elicit_ask`` writes to stderr
53
+ # immediately after creating the elicitation, before it starts polling. Kept
54
+ # as a module constant so producer (here) and any future consumer/tests stay
55
+ # in lockstep on the exact contract string.
56
+ ELICITATION_CREATED_STDERR_PREFIX = "UNIQUE_ELICITATION_CREATED"
57
+
40
58
  # --- Visibility workaround for UN-19815 ---------------------------------
41
59
  #
42
60
  # The chat UI (as of 2026-04-21) only renders an elicitation when the host
@@ -632,6 +650,74 @@ def cmd_elicit_respond(
632
650
  return f"elicit: {exc}"
633
651
 
634
652
 
653
+ def _is_transient_api_failure(exc: BaseException) -> bool:
654
+ """Return ``True`` if *exc* is worth retrying.
655
+
656
+ Transient: connection-level failures (timeouts, resets --
657
+ ``APIConnectionError``) and ``APIError``s with no HTTP status or a 5xx
658
+ status. Not transient: 4xx client errors, which cannot succeed on
659
+ retry -- retrying those would just burn the wait budget on a request
660
+ that is doomed regardless.
661
+
662
+ Note ``InvalidRequestError`` / ``AuthenticationError`` / ``PermissionError``
663
+ are not ``APIError`` subclasses and are not matched here; they propagate
664
+ unchanged, exactly as they did before this retry logic existed.
665
+ """
666
+ if isinstance(exc, unique_sdk.APIConnectionError):
667
+ return True
668
+ if isinstance(exc, unique_sdk.APIError):
669
+ status = getattr(exc, "http_status", None)
670
+ return status is None or status >= 500
671
+ return False
672
+
673
+
674
+ def _get_elicitation_with_retry(
675
+ state: ShellState,
676
+ elicitation_id: str,
677
+ *,
678
+ deadline: float,
679
+ ) -> dict[str, Any]:
680
+ """Fetch an elicitation, retrying transient failures with backoff.
681
+
682
+ Retries are bounded by *deadline* (a ``time.monotonic()`` timestamp) --
683
+ once it passes, the last exception is re-raised so the caller's overall
684
+ ``--timeout`` is always the hard outer bound, never exceeded by retries.
685
+ Non-transient failures (see :func:`_is_transient_api_failure`) are
686
+ re-raised immediately without retrying. Each retry is logged to stderr
687
+ with the attempt number and backoff so a string of dropped connections
688
+ is diagnosable instead of silently eating the wait budget.
689
+ """
690
+ backoff = TRANSIENT_RETRY_BASE_SECONDS
691
+ attempt = 0
692
+ while True:
693
+ try:
694
+ return dict(
695
+ unique_sdk.Elicitation.get_elicitation(
696
+ user_id=state.config.user_id,
697
+ company_id=state.config.company_id,
698
+ elicitation_id=elicitation_id,
699
+ )
700
+ )
701
+ except (unique_sdk.APIError, unique_sdk.APIConnectionError) as exc:
702
+ if not _is_transient_api_failure(exc):
703
+ raise
704
+ remaining = deadline - time.monotonic()
705
+ if remaining <= 0:
706
+ raise
707
+ attempt += 1
708
+ sleep_for = max(
709
+ 0.0, min(backoff, TRANSIENT_RETRY_MAX_BACKOFF_SECONDS, remaining)
710
+ )
711
+ print(
712
+ f"elicit: transient error on attempt {attempt} fetching "
713
+ f"{elicitation_id} ({exc}); retrying in {sleep_for:.1f}s",
714
+ file=sys.stderr,
715
+ )
716
+ if sleep_for > 0:
717
+ time.sleep(sleep_for)
718
+ backoff = min(backoff * 2, TRANSIENT_RETRY_MAX_BACKOFF_SECONDS)
719
+
720
+
635
721
  def cmd_elicit_wait(
636
722
  state: ShellState,
637
723
  elicitation_id: str,
@@ -658,12 +744,10 @@ def cmd_elicit_wait(
658
744
  try:
659
745
  deadline = time.monotonic() + max(1, timeout)
660
746
  while True:
661
- elicitation = unique_sdk.Elicitation.get_elicitation(
662
- user_id=state.config.user_id,
663
- company_id=state.config.company_id,
664
- elicitation_id=elicitation_id,
747
+ elicitation = _get_elicitation_with_retry(
748
+ state, elicitation_id, deadline=deadline
665
749
  )
666
- last = dict(elicitation)
750
+ last = elicitation
667
751
  status = str(elicitation.get("status", "")).upper()
668
752
  if status in TERMINAL_STATUSES:
669
753
  terminal_status = status
@@ -675,12 +759,8 @@ def cmd_elicit_wait(
675
759
  # same instant; this fetch forces the backend's lazy expiry to
676
760
  # run so we report a clean EXPIRED — and publish it to the chat
677
761
  # subscription — instead of a stale PENDING.
678
- final = dict(
679
- unique_sdk.Elicitation.get_elicitation(
680
- user_id=state.config.user_id,
681
- company_id=state.config.company_id,
682
- elicitation_id=elicitation_id,
683
- )
762
+ final = _get_elicitation_with_retry(
763
+ state, elicitation_id, deadline=deadline
684
764
  )
685
765
  last = final
686
766
  final_status = str(final.get("status", "")).upper()
@@ -703,7 +783,7 @@ def cmd_elicit_wait(
703
783
  f"{format_elicitation(last)}"
704
784
  )
705
785
  time.sleep(poll_interval)
706
- except unique_sdk.APIError as exc:
786
+ except (unique_sdk.APIError, unique_sdk.APIConnectionError) as exc:
707
787
  return f"elicit: {exc}"
708
788
  finally:
709
789
  # If the poll loop exited before we ever received a response
@@ -720,7 +800,7 @@ def cmd_elicit_wait(
720
800
  elicitation_id=elicitation_id,
721
801
  )
722
802
  )
723
- except unique_sdk.APIError:
803
+ except (unique_sdk.APIError, unique_sdk.APIConnectionError):
724
804
  last = None
725
805
  ctx = _extract_visibility_context(last)
726
806
  if ctx is not None:
@@ -735,6 +815,36 @@ def cmd_elicit_wait(
735
815
  )
736
816
 
737
817
 
818
+ def _emit_elicitation_created_stderr_line(
819
+ elicitation_id: str,
820
+ expires_at: object,
821
+ expires_in_seconds: int,
822
+ ) -> None:
823
+ """Write the ``UNIQUE_ELICITATION_CREATED`` contract line to stderr.
824
+
825
+ Format (stable, greppable, one line, stderr only)::
826
+
827
+ UNIQUE_ELICITATION_CREATED id=<id> expires_at=<iso8601>
828
+
829
+ Prefers the platform-returned ``expiresAt`` (authoritative); falls back
830
+ to a locally computed timestamp if the platform omitted it, so the line
831
+ is always emitted with a usable value.
832
+ """
833
+ if isinstance(expires_at, str) and expires_at:
834
+ resolved_expires_at = expires_at
835
+ else:
836
+ resolved_expires_at = (
837
+ (datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in_seconds))
838
+ .isoformat(timespec="milliseconds")
839
+ .replace("+00:00", "Z")
840
+ )
841
+ print(
842
+ f"{ELICITATION_CREATED_STDERR_PREFIX} id={elicitation_id} "
843
+ f"expires_at={resolved_expires_at}",
844
+ file=sys.stderr,
845
+ )
846
+
847
+
738
848
  def cmd_elicit_ask(
739
849
  state: ShellState,
740
850
  *,
@@ -745,6 +855,7 @@ def cmd_elicit_ask(
745
855
  message_id: str | None = None,
746
856
  timeout: int = DEFAULT_WAIT_TIMEOUT_SECONDS,
747
857
  poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
858
+ expires_in_seconds: int | None = None,
748
859
  metadata: list[tuple[str, str]] | None = None,
749
860
  visible: bool = True,
750
861
  assistant_id: str | None = None,
@@ -757,6 +868,23 @@ def cmd_elicit_ask(
757
868
  free-text ``answer`` is used. This is the preferred entry point when an
758
869
  agent needs to ask the user a clarifying question.
759
870
 
871
+ ``expires_in_seconds``, when given, decouples the server-side expiry
872
+ (``expiresInSeconds`` sent to the platform) from ``timeout`` (how long
873
+ *this process* blocks polling). When omitted (the default), behaviour is
874
+ unchanged from before this parameter existed: the elicitation expires
875
+ exactly when the local wait gives up, coupling the two. Pass this when
876
+ the caller's own wait budget (e.g. a harness's foreground timeout) is
877
+ shorter than how long a human should realistically have to answer --
878
+ otherwise the elicitation expires under the user before they see it.
879
+
880
+ Immediately after creation (before polling starts), a single line is
881
+ written to stderr for callers that want the elicitation id without
882
+ polling the pending list:
883
+
884
+ UNIQUE_ELICITATION_CREATED id=<id> expires_at=<iso8601>
885
+
886
+ This is stderr-only and does not change stdout's format.
887
+
760
888
  When ``chat_id`` is set and ``visible`` is ``True`` (the default), the
761
889
  elicitation is wrapped in a placeholder thinking timeline so the chat UI
762
890
  actually renders it — see the "Visibility workaround for UN-19815" note
@@ -778,16 +906,17 @@ def cmd_elicit_ask(
778
906
  "required": ["answer"],
779
907
  }
780
908
 
781
- # `ask` exposes a single knob: `--timeout` is both how long we wait and
782
- # when the record expires. The two are always the same here, so the
783
- # backend's default (5 minutes) never leaves the record PENDING after
784
- # we have stopped waiting — which would otherwise prevent the chat UI
785
- # from flipping the prompt to EXPIRED and offering the user a way to
786
- # continue. The poll loop below reads the freshly EXPIRED record and the
787
- # elicitation subscription delivers the terminal status to the chat.
788
- # (Use `elicit create --expires-in` if you need expiry decoupled from a
789
- # local wait.)
790
- expires_in_seconds = timeout
909
+ # By default `ask` exposes a single knob: `--timeout` is both how long
910
+ # we wait and when the record expires. The two are the same unless the
911
+ # caller passes `--expires-in` explicitly, so the backend's default (5
912
+ # minutes) never leaves the record PENDING after we have stopped
913
+ # waiting — which would otherwise prevent the chat UI from flipping
914
+ # the prompt to EXPIRED and offering the user a way to continue. The
915
+ # poll loop below reads the freshly EXPIRED record and the elicitation
916
+ # subscription delivers the terminal status to the chat.
917
+ effective_expires_in_seconds = (
918
+ expires_in_seconds if expires_in_seconds is not None else timeout
919
+ )
791
920
 
792
921
  user_metadata = _parse_metadata_pairs(metadata)
793
922
  effective_message_id = message_id
@@ -836,7 +965,7 @@ def cmd_elicit_ask(
836
965
  url=None,
837
966
  chat_id=chat_id,
838
967
  message_id=effective_message_id,
839
- expires_in_seconds=expires_in_seconds,
968
+ expires_in_seconds=effective_expires_in_seconds,
840
969
  external_elicitation_id=None,
841
970
  metadata=user_metadata,
842
971
  )
@@ -856,6 +985,12 @@ def cmd_elicit_ask(
856
985
  _cleanup_placeholder_if_needed()
857
986
  return "elicit: platform did not return an elicitation id"
858
987
 
988
+ _emit_elicitation_created_stderr_line(
989
+ elicitation_id,
990
+ elicitation.get("expiresAt"),
991
+ effective_expires_in_seconds,
992
+ )
993
+
859
994
  # ``cmd_elicit_wait`` handles its own placeholder teardown by
860
995
  # reading the markers back from the elicitation's metadata.
861
996
  return cmd_elicit_wait(
@@ -89,7 +89,10 @@ OVERVIEW_HELP = textwrap.dedent("""\
89
89
  --chat-id / -c <id> Associated chat ID
90
90
  --message-id / -m <id> Associated message ID
91
91
  --timeout <seconds> Max wait time, also sets when the
92
- request expires (default: 7200)
92
+ request expires unless --expires-in
93
+ is given (default: 7200)
94
+ --expires-in <seconds> Decouple server-side expiry from
95
+ --timeout (defaults to --timeout)
93
96
  --poll-interval <seconds> Poll frequency (default: 2.0)
94
97
  --metadata key=value Metadata (repeatable)
95
98
  --no-visible Skip the UN-19815 visibility workaround
@@ -814,9 +817,12 @@ class UniqueShell(cmd.Cmd):
814
817
  --mode FORM|URL Display mode (create only, default: FORM)
815
818
  --chat-id / -c <id> Associated chat ID
816
819
  --message-id / -m <id> Associated message ID
817
- --expires-in <seconds> Auto-expire the request (create only)
820
+ --expires-in <seconds> Auto-expire the request. For ask, this
821
+ decouples expiry from --timeout
822
+ (defaults to --timeout when omitted)
818
823
  --timeout <seconds> (ask / wait) max wait time, default 7200;
819
824
  for ask this also sets when it expires
825
+ unless --expires-in is given
820
826
  --poll-interval <seconds> (ask / wait) poll frequency, default 2
821
827
  --external-id <id> External identifier (create only)
822
828
  --metadata key=value Metadata (repeatable)
@@ -999,13 +1005,6 @@ class UniqueShell(cmd.Cmd):
999
1005
  if not message:
1000
1006
  self._print("Usage: elicit ask <message> [options]")
1001
1007
  return
1002
- if opts["expires_in_seconds"] is not None:
1003
- self._print(
1004
- "No such option: --expires-in for 'elicit ask'. Use --timeout "
1005
- "(it also sets when the request expires). For expiry decoupled "
1006
- "from a local wait, use 'elicit create --expires-in'."
1007
- )
1008
- return
1009
1008
 
1010
1009
  ask_kwargs: dict[str, Any] = {
1011
1010
  "message": message,
@@ -1014,6 +1013,7 @@ class UniqueShell(cmd.Cmd):
1014
1013
  "chat_id": opts["chat_id"],
1015
1014
  "message_id": opts["message_id"],
1016
1015
  "timeout": opts["timeout"],
1016
+ "expires_in_seconds": opts["expires_in_seconds"],
1017
1017
  "poll_interval": opts["poll_interval"],
1018
1018
  "metadata": opts["metadata"] or None,
1019
1019
  "visible": opts["visible"],
@@ -235,24 +235,29 @@ If you obtained the content some other way (you `download`ed the raw bytes and p
235
235
  CSV, `.txt`, HTML, images) have no pages — always omit `--pages`** and cite the
236
236
  whole file.
237
237
 
238
- **Choosing `--read-method`** (declare the *representation* of the source you actually read):
238
+ **Choosing `--read-method`** (declare the *representation* you actually read the
239
+ cited value from). Pick it by the **modality you read** — NOT by how you *located*
240
+ the page. Locating a page with `unique-cli read` does **not** force `indexed`: if you
241
+ then rendered that page and read a chart/figure with vision, the method is `vision`.
239
242
 
240
243
  - `text` → you used **extracted text** (`pdftotext`, PyMuPDF / `fitz` `page.get_text()`, MarkItDown, or any text extraction).
241
- - `vision` → you read a **rendered image** of the page/slide (e.g. `get_pixmap()`) with your vision capability.
242
- - `indexed` → you relied on **`unique-cli read`** output (the platform's indexed chunks).
244
+ - `vision` → you read a **rendered image** of the page/slide (e.g. `get_pixmap()`) with your vision capability — including when you located the page via `unique-cli read` but then rendered it to read a chart, figure, table, or scanned page.
245
+ - `indexed` → you used the **text returned by `unique-cli read`** (the platform's indexed chunks) as your answer source.
243
246
 
244
247
  | Value | When to use |
245
248
  |-------|-------------|
246
- | `text` | You read the page/document as extracted text and used that text. |
247
- | `vision` | You rendered the page to an image and read it with your vision capability. |
248
- | `indexed` | You read the content via `unique-cli read` (indexed chunks). |
249
-
250
- **Verify page numbers before citing — unless you already have them from `unique-cli read`.**
251
- If you cited straight from `unique-cli read` output (`--read-method indexed`), its
252
- `[p.N]` / `[p.N-M]` markers are already physical positions — skip the checks below
253
- and cite them directly. Otherwise — you read the raw bytes yourself
254
- (`--read-method text`/`vision`) — pick the row matching the file you read and
255
- verify the cited content really is where you claim before calling `cite`:
249
+ | `text` | You extracted the page/document text yourself and used that text. |
250
+ | `vision` | You read a rendered image of the page with your vision capability — even if you located the page via `unique-cli read`. |
251
+ | `indexed` | You used the text returned by `unique-cli read` (indexed chunks) as your source. |
252
+
253
+ **Verify page numbers before citing — unless you located them via `unique-cli read`.**
254
+ This is about the *page number* only, and is **independent of `--read-method`**. If you
255
+ located the page with `unique-cli read`, its `[p.N]` / `[p.N-M]` markers are already
256
+ physical positions — trust them and skip the checks below, even if you then rendered the
257
+ page and read it with vision (in that case still cite `--read-method vision`). Only when
258
+ you obtained the page some other way — you `download`ed the raw bytes and parsed them
259
+ yourself, or `read` returned no `[p.N]` markers — pick the row matching the file you read
260
+ and verify the cited content really is where you claim before calling `cite`:
256
261
 
257
262
  - **PDF** — `pdfinfo file.pdf | grep Pages` for the total physical page count, then
258
263
  for **each** page run `pdftotext -f N -l N file.pdf -` and confirm the content is
@@ -265,11 +270,13 @@ verify the cited content really is where you claim before calling `cite`:
265
270
  - **Non-paginated (XLSX/CSV/TXT/HTML/images)** — there are no pages. Do **NOT**
266
271
  pass `--pages`; cite the whole file and verify the content exists in it.
267
272
 
268
- Then determine `--read-method`: report the representation you actually read. In a
269
- fallback chain (e.g. text extraction returned nothing → render + read visually),
270
- report `text` if you used extracted text or `vision` if you read a rendered image.
271
- Only after verifying, call `unique-cli cite` with the verified page numbers (if any)
272
- and `--read-method`.
273
+ Then determine `--read-method` by the **modality you actually read** — independent of
274
+ how you located the page. In a fallback chain (e.g. text extraction returned nothing →
275
+ render + read visually), report `text` if you used extracted text or `vision` if you read
276
+ a rendered image. If you located the page via `unique-cli read` but read the cited value
277
+ off a rendered image (a chart, figure, table, or scanned page), report `vision`, not
278
+ `indexed`. Only after confirming the page numbers, call `unique-cli cite` with the
279
+ verified page numbers (if any) and `--read-method`.
273
280
 
274
281
  - **One method per `cite` call.** If different pages were read with different methods, issue separate `cite` calls — one per method.
275
282
  - Numbers are **per-turn only**; do not reuse from prior turns.