ms-tau-sdk 2.0.11.dev52__tar.gz → 2.0.12.dev53__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 (86) hide show
  1. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/CHANGELOG.md +33 -0
  2. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +5 -0
  5. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +8 -3
  6. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/a2a.py +156 -22
  7. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/manager.py +18 -2
  8. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/settings.py +20 -0
  9. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/mcp_applications.py +7 -25
  10. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/mcp_connection.py +46 -9
  11. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/.gitignore +0 -0
  12. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/README.md +0 -0
  13. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  14. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  15. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  16. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  17. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  18. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  19. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  20. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  21. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  22. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  23. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  24. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  25. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  26. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/__init__.py +0 -0
  27. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
  28. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  29. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +0 -0
  30. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/__init__.py +0 -0
  31. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/chat.py +0 -0
  32. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/conversations.py +0 -0
  33. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/dependencies.py +0 -0
  34. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/health.py +0 -0
  35. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/inspection.py +0 -0
  36. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/local_chat.py +0 -0
  37. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/models.py +0 -0
  38. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/request_identity.py +0 -0
  39. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/api/sessions.py +0 -0
  40. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/app.py +0 -0
  41. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/application.py +0 -0
  42. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/__init__.py +0 -0
  43. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/assertions.py +0 -0
  44. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/auth.py +0 -0
  45. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/client.py +0 -0
  46. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/local.py +0 -0
  47. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/mcp.py +0 -0
  48. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/models.py +0 -0
  49. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/backend/routes.py +0 -0
  50. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/cli.py +0 -0
  51. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/errors.py +0 -0
  52. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/logging.py +0 -0
  53. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  54. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  55. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  56. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  57. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  58. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  59. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  60. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/providers/__init__.py +0 -0
  61. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/providers/definitions.py +0 -0
  62. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/providers/factory.py +0 -0
  63. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  64. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  65. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/resources/__init__.py +0 -0
  66. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/resources/loader.py +0 -0
  67. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  68. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  69. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  70. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/events.py +0 -0
  71. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  72. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/failures.py +0 -0
  73. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  74. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/observability.py +0 -0
  75. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  76. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/requester.py +0 -0
  77. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/session.py +0 -0
  78. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  79. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  80. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  81. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/sessions/storage.py +0 -0
  82. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/skills.py +0 -0
  83. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/__init__.py +0 -0
  84. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  85. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  86. {ms_tau_sdk-2.0.11.dev52 → ms_tau_sdk-2.0.12.dev53}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.12 — 2026-10-11
4
+
5
+ - **A direct Message that runs out of time gets a typed answer
6
+ ([#94](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/94)).** A `message:send` answered
7
+ with a Message, or JSON-RPC `message/send`, whose turn reached
8
+ `MAINSEQUENCE_TAU_TURN_TIMEOUT_SECONDS` got a plain-text HTTP 500 "Internal Server Error". It now
9
+ gets HTTP 504 `application/a2a+json` with status `DEADLINE_EXCEEDED` and an `ErrorInfo` with
10
+ reason `MESSAGE_RESPONSE_LIMIT_EXCEEDED`, domain `mainsequence.ai` and `metadata.limitSeconds`,
11
+ the limit that expired. JSON-RPC returns the same `ErrorInfo` in a JSON-RPC error (`-32000`).
12
+ The turn is stopped as before; its work is neither rolled back nor replayed. Use Task mode for
13
+ longer work.
14
+ - **A direct Message has the platform's 90-second limit.** The platform gives a direct A2A
15
+ Message one strict limit, 90 seconds, and passes it to the runtime it hosts in
16
+ `MAINSEQUENCE_A2A_MESSAGE_LIMIT_SECONDS`, a platform-set variable that users do not set. The
17
+ runtime enforces it for every caller, from when it receives the request and over the whole
18
+ request, including output-contract repair turns: the turn is stopped and the request gets the
19
+ 504 above with the platform's limit. A shorter `MAINSEQUENCE_TAU_TURN_TIMEOUT_SECONDS` still ends
20
+ the turn first; no setting extends the platform's limit. A Task-mode `message:send` with
21
+ `returnImmediately: false` waits at most until the same limit and returns the Task, which keeps
22
+ running. Streams, chat, caller delivery, Task dispatch and Task turns are unchanged. Without the
23
+ variable, as in local mode (which refuses it), a direct Message has only the turn limit.
24
+ - **Platform MCP tools wait for the limit the platform declares
25
+ ([#93](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/93), ADR 0022 amendment).** Every
26
+ Main Sequence MCP tool call waited `MAINSEQUENCE_TAU_BACKEND_READ_TIMEOUT_SECONDS` (60 seconds by
27
+ default), so an `a2a.send_message` in Message mode whose target answered after 60 seconds was
28
+ abandoned by the calling Agent while the platform still waited and completed the send. A
29
+ platform tool whose `_meta` declares its limit under `mainsequence.ai/timeout-seconds/v1`, the
30
+ key application tools use, is now waited for that long. A tool that declares nothing, or an
31
+ invalid value, keeps the session default; an invalid value is logged as
32
+ `runtime.mcp.invalid_timeout`. `MAINSEQUENCE_TAU_BACKEND_READ_TIMEOUT_SECONDS` now covers only
33
+ ordinary backend reads and platform tools without a declared limit: it has no effect on a Message
34
+ or any platform limit and can only shorten a wait.
35
+
3
36
  ## 2.0.11 — 2026-10-11
4
37
 
5
38
  - **Every MCP connection honors per-call response deadlines
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.11.dev52
3
+ Version: 2.0.12.dev53
4
4
  Summary: Workspace-bound Tau application primitives for Main Sequence projects
5
5
  Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
6
6
  Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "2.0.11.dev52"
7
+ version = "2.0.12.dev53"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -54,6 +54,11 @@ For a deployed Agent, use the projected `a2a.send_message` MCP operation. The pl
54
54
  resolves the target `AgentSession`, waits for its runtime to become ready, and dispatches the
55
55
  message. For asynchronous work, use the returned Task handle with `a2a.wait_task`.
56
56
 
57
+ The platform gives a direct Message (`response_kind: "message"`) 90 seconds, and no setting
58
+ extends that limit. A target that has not answered by then stops its turn, and the send fails
59
+ because of the limit. Send work that may take longer with `response_kind: "task"`, and do not
60
+ resend a Message that ran out of time: the work it already did is not rolled back.
61
+
57
62
  For local Tau development, start `ms-tau` and wait for its local `/ready` result. The public A2A
58
63
  REST and JSON-RPC surfaces support Message and Task execution against the workspace-local SQLite
59
64
  store, including streaming, list/get/cancel/subscribe, and continuation. An A2A protocol Task does
@@ -35,9 +35,9 @@ The SDK looks for the CLI in `MAINSEQUENCE_CLI`, then beside the Python interpre
35
35
  CLI. Do not add token lines to `.env`. A pair found there still works, but it is deprecated and
36
36
  startup logs a warning. `mainsequence refresh-token` run in that directory removes the lines.
37
37
 
38
- Do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`, or the
39
- `MAINSEQUENCE_CALLER_ASSERTION_*` variables. The platform sets them when it hosts a runtime, and
40
- local mode refuses to start with them.
38
+ Do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`, the
39
+ `MAINSEQUENCE_CALLER_ASSERTION_*` variables or `MAINSEQUENCE_A2A_MESSAGE_LIMIT_SECONDS`. The
40
+ platform sets them when it hosts a runtime, and local mode refuses to start with them.
41
41
 
42
42
  Set `MAINSEQUENCE_ENDPOINT` only for a non-default platform endpoint. The CLI session must be for
43
43
  the same endpoint. Local mode binds to `127.0.0.1:8787` by default. Treat an explicit public bind
@@ -118,6 +118,11 @@ To request a Task from `message:send`, send the response-kind extension header
118
118
  response. `message:stream` is always Task-oriented and does not accept `responseKind` or
119
119
  `returnImmediately`.
120
120
 
121
+ Local mode has no platform Message limit: a direct Message ends only at
122
+ `MAINSEQUENCE_TAU_TURN_TIMEOUT_SECONDS`. A hosted Agent answers a direct Message within the
123
+ platform's 90 seconds or with HTTP 504 `DEADLINE_EXCEEDED`, so design work that may take longer as
124
+ a Task.
125
+
121
126
  Task terminality is derived only from its A2A state. `completed`, `failed`, `canceled`, and
122
127
  `rejected` are terminal; `input_required` and `auth_required` are resumable. A normal execution
123
128
  failure produces a durable failed Task and a standard responder `status.message`. A
@@ -382,6 +382,72 @@ def _unsupported_operation_json_rpc_error(
382
382
  }
383
383
 
384
384
 
385
+ class _MessageLimitExceeded(Exception):
386
+ """A Message-mode request reached its time limit before the Agent answered."""
387
+
388
+ def __init__(self, limit_seconds: float) -> None:
389
+ super().__init__(f"The Message time limit of {limit_seconds} seconds expired")
390
+ self.limit_seconds = limit_seconds
391
+
392
+
393
+ def _message_limit_exceeded(context_id: str, limit_seconds: float) -> _MessageLimitExceeded:
394
+ logger.warning(
395
+ "a2a.message.limit_exceeded",
396
+ message="A Message-mode request reached its time limit before the Agent answered",
397
+ context_id=context_id,
398
+ limit_seconds=limit_seconds,
399
+ )
400
+ return _MessageLimitExceeded(limit_seconds)
401
+
402
+
403
+ def _seconds_text(value: float) -> str:
404
+ return str(int(value)) if float(value).is_integer() else str(value)
405
+
406
+
407
+ def _message_limit_text(limit_seconds: float) -> str:
408
+ return (
409
+ f"The Agent did not answer the Message within {_seconds_text(limit_seconds)} seconds. "
410
+ "Use Task mode for longer work."
411
+ )
412
+
413
+
414
+ def _message_limit_error_info(limit_seconds: float) -> dict[str, Any]:
415
+ return {
416
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
417
+ "reason": "MESSAGE_RESPONSE_LIMIT_EXCEEDED",
418
+ "domain": "mainsequence.ai",
419
+ # ErrorInfo metadata values are strings.
420
+ "metadata": {"limitSeconds": _seconds_text(limit_seconds)},
421
+ }
422
+
423
+
424
+ def _message_limit_rest_error(limit_seconds: float) -> JSONResponse:
425
+ return JSONResponse(
426
+ status_code=504,
427
+ media_type="application/a2a+json",
428
+ content={
429
+ "error": {
430
+ "code": 504,
431
+ "status": "DEADLINE_EXCEEDED",
432
+ "message": _message_limit_text(limit_seconds),
433
+ "details": [_message_limit_error_info(limit_seconds)],
434
+ }
435
+ },
436
+ )
437
+
438
+
439
+ def _message_limit_json_rpc_error(request_id: object, limit_seconds: float) -> dict[str, Any]:
440
+ return {
441
+ "jsonrpc": "2.0",
442
+ "id": request_id,
443
+ "error": {
444
+ "code": -32000,
445
+ "message": _message_limit_text(limit_seconds),
446
+ "data": [_message_limit_error_info(limit_seconds)],
447
+ },
448
+ }
449
+
450
+
385
451
  def _without_push_notification_capability(
386
452
  agent_card: dict[str, Any] | None,
387
453
  ) -> dict[str, Any] | None:
@@ -744,6 +810,7 @@ async def _collect_turn(
744
810
  provenance: TurnProvenance,
745
811
  turn_uid: str | None = None,
746
812
  caller_assertion: CallerAssertion | None = None,
813
+ deadline: float | None = None,
747
814
  ) -> str:
748
815
  accumulator = _TurnAccumulator(max_output_bytes=max_output_bytes)
749
816
  prompt_options: dict[str, Any] = {"provenance": provenance}
@@ -751,6 +818,8 @@ async def _collect_turn(
751
818
  prompt_options["turn_uid"] = turn_uid
752
819
  if caller_assertion is not None:
753
820
  prompt_options["caller_assertion"] = caller_assertion
821
+ if deadline is not None:
822
+ prompt_options["deadline"] = deadline
754
823
  async for event in manager.prompt(context_id, prompt, **prompt_options):
755
824
  accumulator.consume(event)
756
825
  context = active_task_execution()
@@ -779,6 +848,7 @@ async def _collect_validated_turn(
779
848
  provenance: TurnProvenance,
780
849
  turn_uid: str | None = None,
781
850
  caller_assertion: CallerAssertion | None = None,
851
+ deadline: float | None = None,
782
852
  ) -> str:
783
853
  text = await _collect_turn(
784
854
  manager,
@@ -788,6 +858,7 @@ async def _collect_validated_turn(
788
858
  provenance=provenance,
789
859
  turn_uid=turn_uid,
790
860
  caller_assertion=caller_assertion,
861
+ deadline=deadline,
791
862
  )
792
863
  if not contract.enabled:
793
864
  return text
@@ -818,6 +889,7 @@ async def _collect_validated_turn(
818
889
  max_output_bytes=max_output_bytes,
819
890
  provenance=provenance,
820
891
  caller_assertion=caller_assertion,
892
+ deadline=deadline,
821
893
  )
822
894
  raise AssertionError("strict JSON repair loop did not terminate")
823
895
 
@@ -948,6 +1020,16 @@ async def _wait_for_task_return_state(
948
1020
  return current
949
1021
 
950
1022
 
1023
+ def _task_wait_seconds(config: TauSDKSettings, deadline: float | None) -> float:
1024
+ """How long a request waits for its Task: the configured wait, ended by the platform's
1025
+ Message limit when the platform sets one. Either way the Task keeps running."""
1026
+
1027
+ if deadline is None:
1028
+ return config.a2a_task_wait_timeout_seconds
1029
+ remaining = deadline - asyncio.get_running_loop().time()
1030
+ return min(config.a2a_task_wait_timeout_seconds, remaining)
1031
+
1032
+
951
1033
  @dataclass(frozen=True, slots=True)
952
1034
  class _ClaimedTask:
953
1035
  attempt_uid: str
@@ -1621,6 +1703,7 @@ async def _execute_message(
1621
1703
  max_output_bytes: int,
1622
1704
  provenance: TurnProvenance,
1623
1705
  caller_assertion: CallerAssertion | None = None,
1706
+ deadline: float | None = None,
1624
1707
  ) -> dict[str, Any]:
1625
1708
  text = await _collect_validated_turn(
1626
1709
  manager,
@@ -1630,6 +1713,7 @@ async def _execute_message(
1630
1713
  max_output_bytes=max_output_bytes,
1631
1714
  provenance=provenance,
1632
1715
  caller_assertion=caller_assertion,
1716
+ deadline=deadline,
1633
1717
  )
1634
1718
  message = _agent_message(
1635
1719
  context_id=context_id,
@@ -2288,6 +2372,32 @@ async def message_send(
2288
2372
  Header(alias="A2A-Extensions"),
2289
2373
  ] = None,
2290
2374
  ) -> dict[str, Any]:
2375
+ try:
2376
+ return await _message_send(body, client, manager, config, request, a2a_extensions)
2377
+ except _MessageLimitExceeded as error:
2378
+ return _message_limit_rest_error(error.limit_seconds) # type: ignore[return-value]
2379
+
2380
+
2381
+ async def _message_send(
2382
+ body: dict[str, Any],
2383
+ client: MainSequenceClient,
2384
+ manager: SessionRuntimeManager,
2385
+ config: TauSDKSettings,
2386
+ request: Request,
2387
+ a2a_extensions: str | None,
2388
+ ) -> dict[str, Any]:
2389
+ """Answer one ``message:send``, for REST and JSON-RPC alike.
2390
+
2391
+ When the platform sets a Message limit, it runs from the moment the request arrives. It
2392
+ bounds a Message-mode request as a whole, including output-contract repair turns, and it ends
2393
+ the wait of a Task-mode request that does not return immediately. A Message-mode request that
2394
+ reaches its limit, or its turn's own limit, raises ``_MessageLimitExceeded``, which each
2395
+ transport answers in its own form.
2396
+ """
2397
+ platform_limit = config.a2a_message_limit_seconds
2398
+ deadline = (
2399
+ None if platform_limit is None else asyncio.get_running_loop().time() + platform_limit
2400
+ )
2291
2401
  try:
2292
2402
  provenance = _a2a_turn_provenance(config, request)
2293
2403
  except CallerIdentityError as error:
@@ -2362,7 +2472,7 @@ async def message_send(
2362
2472
  client,
2363
2473
  task,
2364
2474
  poll_interval_seconds=config.a2a_task_event_poll_interval_seconds,
2365
- wait_timeout_seconds=config.a2a_task_wait_timeout_seconds,
2475
+ wait_timeout_seconds=_task_wait_seconds(config, deadline),
2366
2476
  )
2367
2477
  task = await _task_with_history(client, task, history_length=history_length)
2368
2478
  return {"task": _task_payload(task, history_length=history_length)}
@@ -2397,7 +2507,7 @@ async def message_send(
2397
2507
  client,
2398
2508
  task,
2399
2509
  poll_interval_seconds=config.a2a_task_event_poll_interval_seconds,
2400
- wait_timeout_seconds=config.a2a_task_wait_timeout_seconds,
2510
+ wait_timeout_seconds=_task_wait_seconds(config, deadline),
2401
2511
  )
2402
2512
  task = await _task_with_history(client, task, history_length=history_length)
2403
2513
  return {"task": _task_payload(task, history_length=history_length)}
@@ -2422,31 +2532,52 @@ async def message_send(
2422
2532
  client,
2423
2533
  task,
2424
2534
  poll_interval_seconds=config.a2a_task_event_poll_interval_seconds,
2425
- wait_timeout_seconds=config.a2a_task_wait_timeout_seconds,
2535
+ wait_timeout_seconds=_task_wait_seconds(config, deadline),
2426
2536
  )
2427
2537
  task = await _task_with_history(client, task, history_length=history_length)
2428
2538
  return {"task": _task_payload(task, history_length=history_length)}
2429
2539
  request_message_id = str(message["messageId"])
2540
+ context_id = str(message["contextId"])
2430
2541
  if config.local_mode:
2431
2542
  replay = await client.begin_local_conversation_message(message_to_protocol(message))
2432
2543
  if replay is not None:
2433
2544
  return {"message": replay}
2434
- result = await _execute_message(
2435
- manager,
2436
- context_id=str(message["contextId"]),
2437
- prompt=prompt,
2438
- output_contract=output_contract,
2439
- max_output_bytes=config.max_turn_output_bytes,
2440
- provenance=provenance,
2441
- caller_assertion=request_caller_assertion(request),
2442
- )
2545
+ if (
2546
+ platform_limit is not None
2547
+ and deadline is not None
2548
+ and asyncio.get_running_loop().time() >= deadline
2549
+ ):
2550
+ # The request used up the platform's limit before its turn could start, so none starts.
2551
+ raise _message_limit_exceeded(context_id, platform_limit)
2552
+ request_limit = asyncio.timeout_at(deadline)
2553
+ try:
2554
+ async with request_limit:
2555
+ result = await _execute_message(
2556
+ manager,
2557
+ context_id=context_id,
2558
+ prompt=prompt,
2559
+ output_contract=output_contract,
2560
+ max_output_bytes=config.max_turn_output_bytes,
2561
+ provenance=provenance,
2562
+ caller_assertion=request_caller_assertion(request),
2563
+ deadline=deadline,
2564
+ )
2565
+ except TimeoutError as error:
2566
+ # The platform's limit for the request or the turn's own limit expired, and the turn was
2567
+ # stopped. The caller learns which limit.
2568
+ raise _message_limit_exceeded(
2569
+ context_id,
2570
+ platform_limit
2571
+ if platform_limit is not None and request_limit.expired()
2572
+ else config.turn_timeout_seconds,
2573
+ ) from error
2443
2574
  if config.local_mode:
2444
2575
  result = await client.complete_local_conversation_message(
2445
- str(message["contextId"]),
2576
+ context_id,
2446
2577
  request_message_id,
2447
2578
  result,
2448
2579
  )
2449
- manager.mark_response_delivered(str(message["contextId"]))
2580
+ manager.mark_response_delivered(context_id)
2450
2581
  return {"message": result}
2451
2582
 
2452
2583
 
@@ -2703,14 +2834,17 @@ async def json_rpc(
2703
2834
 
2704
2835
  try:
2705
2836
  if method in {"SendMessage", "message/send"}:
2706
- result = await message_send(
2707
- params,
2708
- client,
2709
- manager,
2710
- config,
2711
- request,
2712
- a2a_extensions,
2713
- )
2837
+ try:
2838
+ result = await _message_send(
2839
+ params,
2840
+ client,
2841
+ manager,
2842
+ config,
2843
+ request,
2844
+ a2a_extensions,
2845
+ )
2846
+ except _MessageLimitExceeded as error:
2847
+ return _message_limit_json_rpc_error(request_id, error.limit_seconds)
2714
2848
  elif method in {"SendStreamingMessage", "message/stream"}:
2715
2849
  _response_kind(
2716
2850
  params,
@@ -1325,6 +1325,7 @@ class SessionRuntimeManager:
1325
1325
  turn_uid: str | None = None,
1326
1326
  caller_assertion: CallerAssertion | None = None,
1327
1327
  caller_delivery: CallerDelivery | None = None,
1328
+ deadline: float | None = None,
1328
1329
  ) -> AsyncIterator[TauRuntimeEvent]:
1329
1330
  """Run one turn in a session.
1330
1331
 
@@ -1332,6 +1333,9 @@ class SessionRuntimeManager:
1332
1333
  request that starts the turn. It is presented only when the turn is marked active.
1333
1334
  ``caller_delivery`` is, instead, the caller delivery that a turn the platform starts
1334
1335
  resumes the session for. It is named only when the turn is marked active.
1336
+ ``deadline`` is the event-loop time by which the request that waits for the turn must be
1337
+ answered. The turn then ends at that deadline or at its own time limit, whichever comes
1338
+ first, and either one stops it the same way.
1335
1339
  """
1336
1340
 
1337
1341
  agent_uid = await self._session_agent_uid(session_uid)
@@ -1351,6 +1355,7 @@ class SessionRuntimeManager:
1351
1355
  platform_event=platform_event,
1352
1356
  caller_assertion=caller_assertion,
1353
1357
  caller_delivery=caller_delivery,
1358
+ deadline=deadline,
1354
1359
  ):
1355
1360
  yield event
1356
1361
 
@@ -1375,6 +1380,7 @@ class SessionRuntimeManager:
1375
1380
  platform_event: PlatformEvent | None = None,
1376
1381
  caller_assertion: CallerAssertion | None = None,
1377
1382
  caller_delivery: CallerDelivery | None = None,
1383
+ deadline: float | None = None,
1378
1384
  ) -> AsyncIterator[TauRuntimeEvent]:
1379
1385
  live_turn = self._open_live_turn(session_uid, content, provenance)
1380
1386
  try:
@@ -1385,6 +1391,7 @@ class SessionRuntimeManager:
1385
1391
  platform_event=platform_event,
1386
1392
  caller_assertion=caller_assertion,
1387
1393
  caller_delivery=caller_delivery,
1394
+ deadline=deadline,
1388
1395
  ):
1389
1396
  if live_turn is not None:
1390
1397
  live_turn.observe(event)
@@ -1518,6 +1525,7 @@ class SessionRuntimeManager:
1518
1525
  platform_event: PlatformEvent | None = None,
1519
1526
  caller_assertion: CallerAssertion | None = None,
1520
1527
  caller_delivery: CallerDelivery | None = None,
1528
+ deadline: float | None = None,
1521
1529
  ) -> AsyncIterator[TauRuntimeEvent]:
1522
1530
  logger.info(
1523
1531
  "agent.run.accepted",
@@ -1612,10 +1620,12 @@ class SessionRuntimeManager:
1612
1620
  model=runtime.model,
1613
1621
  is_streaming=True,
1614
1622
  )
1623
+ limit_at = asyncio.get_running_loop().time() + self.settings.turn_timeout_seconds
1624
+ turn_limit = asyncio.timeout_at(limit_at if deadline is None else min(limit_at, deadline))
1615
1625
  requester_token = bind_turn_requester(requester_binding)
1616
1626
  try:
1617
1627
  try:
1618
- async with asyncio.timeout(self.settings.turn_timeout_seconds):
1628
+ async with turn_limit:
1619
1629
  async for event in runtime.prompt(
1620
1630
  content,
1621
1631
  provenance=provenance,
@@ -1640,7 +1650,13 @@ class SessionRuntimeManager:
1640
1650
  runtime.cancel(force=True)
1641
1651
  raise
1642
1652
  except asyncio.CancelledError:
1643
- if not output_complete:
1653
+ if turn_limit.expired():
1654
+ # The request's own timer for the same deadline cancelled the turn in the same
1655
+ # step, so the turn's limit could not report itself. It still expired.
1656
+ terminal_status = "timed_out"
1657
+ error_type = "TimeoutError"
1658
+ runtime.cancel(force=True)
1659
+ elif not output_complete:
1644
1660
  terminal_status = "cancelled"
1645
1661
  error_type = "CancelledError"
1646
1662
  raise
@@ -29,6 +29,7 @@ CALLER_ASSERTION_JWKS_URL_ENV = "MAINSEQUENCE_CALLER_ASSERTION_JWKS_URL"
29
29
  RESOURCE_RELEASE_UID_ENV = "APP_NAME"
30
30
  FASTAPI_PUBLIC_BASE_URL_ENV = "FASTAPI_PUBLIC_BASE_URL"
31
31
  ORGANIZATION_ENVIRONMENT_UID_ENV = "MAINSEQUENCE_ORGANIZATION_ENVIRONMENT_UID"
32
+ A2A_MESSAGE_LIMIT_ENV = "MAINSEQUENCE_A2A_MESSAGE_LIMIT_SECONDS"
32
33
  HOSTED_SIGNAL_ENVS = (
33
34
  RESOURCE_RELEASE_UID_ENV,
34
35
  FASTAPI_PUBLIC_BASE_URL_ENV,
@@ -138,6 +139,14 @@ class TauSDKSettings(BaseSettings):
138
139
  default=None,
139
140
  validation_alias=ORGANIZATION_ENVIRONMENT_UID_ENV,
140
141
  )
142
+ # The platform's limit for a direct A2A Message, which holds its request open until the Agent
143
+ # answers. The platform sets it when it hosts the runtime; unset means no platform limit.
144
+ a2a_message_limit_seconds: float | None = Field(
145
+ default=None,
146
+ gt=0,
147
+ allow_inf_nan=False,
148
+ validation_alias=A2A_MESSAGE_LIMIT_ENV,
149
+ )
141
150
  local_mode: bool = Field(default=False, validation_alias="TAU_LOCAL_MODE")
142
151
  exclude_base_tools: bool = Field(default=False, validation_alias="TAU_EXCLUDE_BASE_TOOLS")
143
152
  exclude_mainsequence_mcp: bool = Field(
@@ -404,6 +413,12 @@ class TauSDKSettings(BaseSettings):
404
413
  # name a mode exactly.
405
414
  return None if value == "" else value
406
415
 
416
+ @field_validator("a2a_message_limit_seconds", mode="before")
417
+ @classmethod
418
+ def ignore_empty_message_limit(cls, value: object) -> object:
419
+ # Only an empty value counts as unset. Any other value must be a number of seconds.
420
+ return None if value == "" else value
421
+
407
422
  @field_validator("local_state_root", "state_root")
408
423
  @classmethod
409
424
  def normalize_state_roots(cls, value: Path) -> Path:
@@ -427,6 +442,11 @@ class TauSDKSettings(BaseSettings):
427
442
  f"{CALLER_AUTH_MODE_ENV}=assertion and {', '.join(HOSTED_SIGNAL_ENVS)}: "
428
443
  "the platform sets them only when it hosts the runtime"
429
444
  )
445
+ if self.a2a_message_limit_seconds is not None:
446
+ raise ValueError(
447
+ "TAU_LOCAL_MODE has no platform Message limit. Unset "
448
+ f"{A2A_MESSAGE_LIMIT_ENV}: the platform sets it only when it hosts the runtime"
449
+ )
430
450
  if self.host == "0.0.0.0" and "host" not in self.model_fields_set:
431
451
  self.host = "127.0.0.1"
432
452
  elif self.auth_mode != "runtime_credential":
@@ -17,7 +17,6 @@ opening the session is tried once more when it fails before the tool call is sen
17
17
  from __future__ import annotations
18
18
 
19
19
  import json
20
- import math
21
20
  import re
22
21
  from collections.abc import AsyncGenerator, Mapping, Sequence
23
22
  from typing import Protocol
@@ -49,16 +48,14 @@ from ms_tau_sdk.runtime.requester import (
49
48
  _TurnApplicationAccess,
50
49
  )
51
50
  from ms_tau_sdk.settings import TauSDKSettings
52
- from ms_tau_sdk.tools.mcp_connection import mcp_tool_result
51
+ from ms_tau_sdk.tools.mcp_connection import TIMEOUT_META_KEY as TIMEOUT_META_KEY
52
+ from ms_tau_sdk.tools.mcp_connection import declared_timeout, mcp_tool_result
53
53
 
54
54
  logger = structlog.get_logger(__name__)
55
55
 
56
56
  _APPLICATION_NAME = re.compile(r"^[a-z][a-z0-9_]{0,39}$")
57
57
  _RESERVED_NAMES = frozenset({"mainsequence"})
58
58
 
59
- TIMEOUT_META_KEY = "mainsequence.ai/timeout-seconds/v1"
60
- """The ``_meta`` key under which an application tool declares how long it may run, in seconds."""
61
-
62
59
 
63
60
  class ApplicationConnector(Protocol):
64
61
  async def __call__(
@@ -204,25 +201,6 @@ def _retryable(error: BaseException) -> bool:
204
201
  return isinstance(error, httpx.TransportError) or _timed_out(error)
205
202
 
206
203
 
207
- def _declared_timeout(name: str, tool: types.Tool) -> float | None:
208
- value = (tool.meta or {}).get(TIMEOUT_META_KEY)
209
- if value is None:
210
- return None
211
- if (
212
- isinstance(value, bool)
213
- or not isinstance(value, int | float)
214
- or not math.isfinite(value)
215
- or value <= 0
216
- ):
217
- logger.warning(
218
- "runtime.mcp_application.invalid_timeout",
219
- application=name,
220
- mcp_tool=tool.name,
221
- )
222
- return None
223
- return float(value)
224
-
225
-
226
204
  def _error(name: str, text: str, **details: JSONValue) -> AgentToolResult:
227
205
  return AgentToolResult(
228
206
  content=[TextContent(text=text)],
@@ -339,7 +317,11 @@ def _application_tools(
339
317
  try:
340
318
  declared = next(
341
319
  (
342
- _declared_timeout(name, tool)
320
+ declared_timeout(
321
+ tool,
322
+ invalid_event="runtime.mcp_application.invalid_timeout",
323
+ application=name,
324
+ )
343
325
  for tool in client.tools
344
326
  if tool.name == tool_name
345
327
  ),
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
6
7
  import re
7
8
  from collections.abc import Callable, Mapping
8
9
  from copy import deepcopy
9
10
  from dataclasses import dataclass
10
- from typing import Literal, Protocol, cast
11
+ from typing import Any, Literal, Protocol, cast
11
12
 
13
+ import structlog
12
14
  from mcp import types
13
15
  from tau_agent.messages import ImageContent, TextContent
14
16
  from tau_agent.tools import (
@@ -19,8 +21,39 @@ from tau_agent.tools import (
19
21
  )
20
22
  from tau_agent.types import JSONValue
21
23
 
24
+ logger = structlog.get_logger(__name__)
25
+
22
26
  _INVALID_TOOL_NAME = re.compile(r"[^A-Za-z0-9_-]")
23
27
 
28
+ TIMEOUT_META_KEY = "mainsequence.ai/timeout-seconds/v1"
29
+ """The ``_meta`` key under which an MCP tool declares how long it may run, in seconds."""
30
+
31
+
32
+ def declared_timeout(
33
+ tool: types.Tool,
34
+ *,
35
+ invalid_event: str,
36
+ **log_fields: JSONValue,
37
+ ) -> float | None:
38
+ """Return the time limit ``tool`` declares under ``TIMEOUT_META_KEY``, in seconds.
39
+
40
+ None when it declares none. A declaration that is not a finite number greater than zero
41
+ counts as none, and is logged as ``invalid_event`` with the tool's name and ``log_fields``.
42
+ """
43
+
44
+ value = (tool.meta or {}).get(TIMEOUT_META_KEY)
45
+ if value is None:
46
+ return None
47
+ if (
48
+ isinstance(value, bool)
49
+ or not isinstance(value, int | float)
50
+ or not math.isfinite(value)
51
+ or value <= 0
52
+ ):
53
+ logger.warning(invalid_event, mcp_tool=tool.name, **log_fields)
54
+ return None
55
+ return float(value)
56
+
24
57
 
25
58
  class MCPConnection(Protocol):
26
59
  """The part of an MCP connection client that the adapter uses."""
@@ -34,6 +67,7 @@ class MCPConnection(Protocol):
34
67
  arguments: dict[str, object],
35
68
  *,
36
69
  meta: dict[str, object] | None = None,
70
+ timeout_seconds: float | None = None,
37
71
  ) -> types.CallToolResult: ...
38
72
 
39
73
  async def read_resource(self, uri: str) -> types.ReadResourceResult: ...
@@ -129,6 +163,11 @@ def _create_tool(
129
163
  and bool(getattr(annotations, "idempotentHint", False))
130
164
  else "sequential"
131
165
  )
166
+ # A tool that declares its time limit is waited for that long; any other tool gets the
167
+ # connection's default wait.
168
+ timeout_seconds = declared_timeout(
169
+ tool, invalid_event="runtime.mcp.invalid_timeout", connection=policy.prefix
170
+ )
132
171
 
133
172
  async def execute(
134
173
  tool_call_id: str,
@@ -145,14 +184,12 @@ def _create_tool(
145
184
  if policy.check_arguments is not None:
146
185
  policy.check_arguments(canonical_name, arguments)
147
186
  private_meta = policy.private_meta(tool) if policy.private_meta is not None else None
148
- if private_meta is None:
149
- result = await client.call_tool(canonical_name, dict(arguments))
150
- else:
151
- result = await client.call_tool(
152
- canonical_name,
153
- dict(arguments),
154
- meta=dict(private_meta),
155
- )
187
+ call_options: dict[str, Any] = {}
188
+ if private_meta is not None:
189
+ call_options["meta"] = dict(private_meta)
190
+ if timeout_seconds is not None:
191
+ call_options["timeout_seconds"] = timeout_seconds
192
+ result = await client.call_tool(canonical_name, dict(arguments), **call_options)
156
193
  return mcp_tool_result(
157
194
  canonical_name=canonical_name,
158
195
  result=result,