ms-tau-sdk 2.0.3.dev36__tar.gz → 2.0.3.dev37__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 (85) hide show
  1. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/CHANGELOG.md +13 -0
  2. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/PKG-INFO +3 -2
  3. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/README.md +2 -1
  4. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/pyproject.toml +1 -1
  5. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +3 -2
  6. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +3 -3
  7. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/a2a.py +1 -1
  8. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/request_identity.py +91 -18
  9. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/assertions.py +3 -0
  10. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/client.py +8 -0
  11. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/models.py +15 -0
  12. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/routes.py +5 -0
  13. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/manager.py +1 -1
  14. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/.gitignore +0 -0
  15. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  16. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  17. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  18. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  19. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  20. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  21. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  22. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  23. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  24. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  25. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  26. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  27. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  28. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/__init__.py +0 -0
  29. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  30. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
  31. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/__init__.py +0 -0
  32. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/chat.py +0 -0
  33. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/conversations.py +0 -0
  34. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/dependencies.py +0 -0
  35. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/health.py +0 -0
  36. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/inspection.py +0 -0
  37. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/local_chat.py +0 -0
  38. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/models.py +0 -0
  39. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/api/sessions.py +0 -0
  40. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/app.py +0 -0
  41. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/application.py +0 -0
  42. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/__init__.py +0 -0
  43. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/auth.py +0 -0
  44. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/local.py +0 -0
  45. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/backend/mcp.py +0 -0
  46. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/cli.py +0 -0
  47. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/errors.py +0 -0
  48. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/logging.py +0 -0
  49. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  50. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  51. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  52. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  53. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  54. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  55. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  56. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/providers/__init__.py +0 -0
  57. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/providers/definitions.py +0 -0
  58. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/providers/factory.py +0 -0
  59. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  60. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  61. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/resources/__init__.py +0 -0
  62. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/resources/loader.py +0 -0
  63. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  64. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  65. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  66. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/events.py +0 -0
  67. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  68. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/failures.py +0 -0
  69. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  70. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/observability.py +0 -0
  71. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  72. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/requester.py +0 -0
  73. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/secret_entry.py +0 -0
  74. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/session.py +0 -0
  75. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  76. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  77. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  78. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/sessions/storage.py +0 -0
  79. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/settings.py +0 -0
  80. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/skills.py +0 -0
  81. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/tools/__init__.py +0 -0
  82. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  83. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/tools/secret_entry.py +0 -0
  84. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  85. {ms_tau_sdk-2.0.3.dev36 → ms_tau_sdk-2.0.3.dev37}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -51,6 +51,19 @@
51
51
  `GET /api/chat/model-providers`. The Environment is now sent as the `organization_environment_uid`
52
52
  query parameter, as the other list calls send theirs; the call without an Environment is
53
53
  unchanged ([issue #68](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/68)).
54
+ - A hosted runtime admits the Agent that delegated to a child session. When Agent A delegates to
55
+ Agent B through `a2a.send_message`, the platform creates B's child session for the person who
56
+ owns A's session, and A addresses it with its own credential, so the caller assertion names A's
57
+ workload User. B's runtime refused every such request with 403. It now also admits that caller
58
+ when the session's `parent_session_agent_uid` names an Agent and the platform's directory
59
+ (`GET /api/v1/users/<uid>/`, read with the runtime credential) shows the caller as a workload
60
+ User whose `agent_uid` is that Agent: Message send and stream, JSON-RPC, and Task continuation,
61
+ reads, list and cancel. The `X-Caller-*` headers never admit a caller. A session without a
62
+ parent, another Agent's workload, and a person who is not the owner still get 403, and a lookup
63
+ that fails refuses the request. One request looks a User up at most once, and nothing is kept
64
+ after it. The admitted Agent is never a requester: `current_requester()` stays `None` in its
65
+ turns and in the Task attempts its requests run. See the 2026-10-06 amendment of ADR 0019
66
+ ([issue #67](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/67)).
54
67
 
55
68
  ## 2.0.2 — 2026-10-06
56
69
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.3.dev36
3
+ Version: 2.0.3.dev37
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
@@ -126,7 +126,8 @@ app = create_app()
126
126
 
127
127
  When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
128
128
  signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
129
- only a session's owner or an Organization admin address that session. It declares this request
129
+ only a session's owner, an Organization admin, or the Agent that delegated to it address that
130
+ session. It declares this request
130
131
  identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
131
132
  only an application that declares it, so serve the application `create_app()` returns. Outside
132
133
  hosting, requests are handled as before. See
@@ -93,7 +93,8 @@ app = create_app()
93
93
 
94
94
  When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
95
95
  signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
96
- only a session's owner or an Organization admin address that session. It declares this request
96
+ only a session's owner, an Organization admin, or the Agent that delegated to it address that
97
+ session. It declares this request
97
98
  identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
98
99
  only an application that declares it, so serve the application `create_app()` returns. Outside
99
100
  hosting, requests are handled as before. See
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "2.0.3.dev36"
7
+ version = "2.0.3.dev37"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -26,8 +26,9 @@ The SDK owns:
26
26
  - privately attaching active caller-session proof when a tool advertises
27
27
  `mainsequence.ai/requires-caller-session-proof/v1: true`;
28
28
  - verifying the platform's signed caller or platform assertion on every inbound request of a
29
- hosted runtime, and admitting a request to a session or its Tasks only for the session's owner
30
- or an Organization admin;
29
+ hosted runtime, and admitting a request to a session or its Tasks only for the session's owner,
30
+ an Organization admin, or the workload User of the Agent that delegated to that child session,
31
+ which the platform's own records establish, never the `X-Caller-*` headers;
31
32
  - presenting the verified caller assertion of the request that starts a chat or A2A Message turn
32
33
  only when it marks that turn active, so that the platform can record the turn's requester, and
33
34
  giving extension tools that requester and requester-bound calls through `current_requester()`
@@ -46,9 +46,9 @@ TAU image, overlay, or SDK-owned deployment.
46
46
 
47
47
  `create_app()` installs the runtime's request identity. When Main Sequence hosts the runtime, the
48
48
  application admits only requests that carry the platform's signed assertion, lets only a session's
49
- owner or an Organization admin address that session, and declares all this in
50
- `app.state.mainsequence_request_identity`. The platform launcher serves an application only with
51
- that declaration, read from the object it serves. Therefore:
49
+ owner, an Organization admin, or the Agent that delegated to it address that session, and declares
50
+ all this in `app.state.mainsequence_request_identity`. The platform launcher serves an application
51
+ only with that declaration, read from the object it serves. Therefore:
52
52
 
53
53
  - export the object `create_app()` returns as the module's `app`. An application that mounts it
54
54
  inside another one declares nothing, and the launcher refuses it;
@@ -930,7 +930,7 @@ def _requester_named_by_task(
930
930
  answer must name the same User, as a person, as ``requester_user_uid``.
931
931
  """
932
932
 
933
- if caller_assertion is None:
933
+ if caller_assertion is None or caller_assertion.caller_is_workload:
934
934
  return None
935
935
  caller = caller_assertion.caller
936
936
  if task.requester_identity_type != "human" or task.requester_user_uid != caller.uid:
@@ -8,8 +8,9 @@ the kind its route requires: a platform assertion on ``/internal/*``, a caller a
8
8
  other route. Handlers read the verified caller with ``current_caller()``, or from
9
9
  ``request.state.user`` and ``request.state.user_uid``.
10
10
 
11
- A request that addresses an existing session must also come from the session's owner or from an
12
- Organization admin; see ``require_session_access()``. Outside hosted mode nothing here changes how
11
+ A request that addresses an existing session must also come from the session's owner, from an
12
+ Organization admin, or, for a delegated child session, from the workload User of the Agent that
13
+ delegated to it; see ``require_session_access()``. Outside hosted mode nothing here changes how
13
14
  requests are handled.
14
15
 
15
16
  The raw caller assertion stays private to the request: ``request_caller_assertion()`` hands it
@@ -18,11 +19,13 @@ only to the turn that request starts, which presents it when it marks the turn a
18
19
 
19
20
  from __future__ import annotations
20
21
 
22
+ import dataclasses
21
23
  from collections.abc import Iterable
22
24
  from contextvars import ContextVar
23
25
 
24
26
  import structlog
25
27
  from fastapi import FastAPI, HTTPException, Request
28
+ from pydantic import ValidationError
26
29
  from starlette.responses import JSONResponse
27
30
  from starlette.types import ASGIApp, Receive, Scope, Send
28
31
  from starlette.websockets import WebSocketClose
@@ -37,8 +40,8 @@ from ms_tau_sdk.backend.assertions import (
37
40
  VerifiedCaller,
38
41
  )
39
42
  from ms_tau_sdk.backend.client import MainSequenceClient
40
- from ms_tau_sdk.backend.models import AgentSession
41
- from ms_tau_sdk.errors import SessionNotFoundError
43
+ from ms_tau_sdk.backend.models import AgentSession, DirectoryUser
44
+ from ms_tau_sdk.errors import SessionNotFoundError, TauSDKError
42
45
  from ms_tau_sdk.logging import bind_request_identity_log_fields
43
46
  from ms_tau_sdk.runtime.provenance import CallerIdentityError
44
47
  from ms_tau_sdk.settings import TauSDKSettings
@@ -66,6 +69,12 @@ _current_caller: ContextVar[VerifiedCaller | None] = ContextVar(
66
69
  # The verified raw caller assertion is kept in the request's own scope, not in request.state and
67
70
  # not in a context variable, so that only the code that starts the request's turn reads it.
68
71
  _CALLER_ASSERTION_SCOPE_KEY = "ms_tau_sdk.caller_assertion"
72
+ # The workload Users that one request looked up, by User UID: the workload, or None for a User
73
+ # that is not a workload or whose lookup failed. It lives only as long as its request.
74
+ _workload_lookups: ContextVar[dict[str, DirectoryUser | None] | None] = ContextVar(
75
+ "ms_tau_sdk_workload_lookups",
76
+ default=None,
77
+ )
69
78
 
70
79
 
71
80
  class SessionAccessDeniedError(HTTPException):
@@ -85,15 +94,24 @@ def current_caller() -> VerifiedCaller | None:
85
94
 
86
95
 
87
96
  def request_caller_assertion(request: Request) -> CallerAssertion | None:
88
- """Return the verified caller assertion of ``request`` for the turn it starts.
97
+ """Return the verified caller assertion of ``request`` for the turn or Task it starts.
89
98
 
90
- It is None outside hosted mode and on the platform's own calls. Only the chat and A2A Message
91
- routes read it, to present it when they mark their turn active; it is never logged, persisted,
92
- or passed to the model, a tool, a stream, or history.
99
+ It is None outside hosted mode and on the platform's own calls. Only the chat and A2A routes
100
+ read it, to present it when they mark their turn active or create or continue a Task; it is
101
+ never logged, persisted, or passed to the model, a tool, a stream, or history. A caller this
102
+ request admitted as the Agent that delegated to the session comes back marked as a workload,
103
+ which is never a requester.
93
104
  """
94
105
 
95
106
  value = request.scope.get(_CALLER_ASSERTION_SCOPE_KEY)
96
- return value if isinstance(value, CallerAssertion) else None
107
+ if not isinstance(value, CallerAssertion):
108
+ return None
109
+ lookups = _workload_lookups.get()
110
+ if lookups is not None and lookups.get(value.caller.uid) is not None:
111
+ # The request was admitted as the Agent that delegated to the session: its assertion names
112
+ # a workload, never a requester.
113
+ return dataclasses.replace(value, caller_is_workload=True)
114
+ return value
97
115
 
98
116
 
99
117
  def verified_user_uid(settings: TauSDKSettings) -> str | None:
@@ -111,15 +129,63 @@ def verified_user_uid(settings: TauSDKSettings) -> str | None:
111
129
  return caller.uid
112
130
 
113
131
 
132
+ async def _workload_of(client: MainSequenceClient, user_uid: str) -> DirectoryUser | None:
133
+ """Return the workload User ``user_uid`` names, as the platform's directory shows it, or None.
134
+
135
+ A User that is not a workload, and a lookup that fails, give None, so the check fails closed.
136
+ The answer is kept for the rest of the request only.
137
+ """
138
+
139
+ lookups = _workload_lookups.get()
140
+ if lookups is not None and user_uid in lookups:
141
+ return lookups[user_uid]
142
+ workload: DirectoryUser | None = None
143
+ try:
144
+ found = await client.get_user(user_uid)
145
+ except (TauSDKError, ValidationError) as error:
146
+ logger.warning(
147
+ "request_identity.caller_lookup.failed",
148
+ message="The caller's User could not be looked up; the session is refused",
149
+ error_type=type(error).__name__,
150
+ )
151
+ else:
152
+ if found.uid == user_uid and found.identity_type == "workload" and found.agent_uid:
153
+ workload = found
154
+ if lookups is not None:
155
+ lookups[user_uid] = workload
156
+ return workload
157
+
158
+
159
+ async def _delegated_to_caller(
160
+ client: MainSequenceClient,
161
+ session: AgentSession,
162
+ caller: VerifiedCaller,
163
+ ) -> bool:
164
+ """Whether ``session`` is a child session that the caller's own Agent delegated to.
165
+
166
+ The session must name its parent session's Agent, the caller must be a workload User as the
167
+ platform's directory shows it, and that workload must serve the same Agent. No request header
168
+ is read.
169
+ """
170
+
171
+ parent_agent_uid = session.parent_session_agent_uid
172
+ if not parent_agent_uid:
173
+ return False
174
+ workload = await _workload_of(client, caller.uid)
175
+ return workload is not None and workload.agent_uid == parent_agent_uid
176
+
177
+
114
178
  async def require_session_access(
115
179
  client: MainSequenceClient,
116
180
  session_uid: str,
117
181
  ) -> AgentSession | None:
118
- """Admit the current request to ``session_uid`` only for its owner or an Organization admin.
182
+ """Admit the current request to ``session_uid`` only for a caller allowed to address it.
119
183
 
120
- The owner is the User the platform recorded as the session's creator,
121
- ``created_by_user_uid``. The check applies in hosted mode only and raises
122
- ``SessionAccessDeniedError`` (403). It returns the session when it had to read it.
184
+ That is the session's owner, the User the platform recorded as its creator
185
+ (``created_by_user_uid``); an Organization admin; or, for a delegated child session, the
186
+ workload User of the Agent that delegated to it (``parent_session_agent_uid``). The check
187
+ applies in hosted mode only and raises ``SessionAccessDeniedError`` (403). It returns the
188
+ session when it had to read it.
123
189
  """
124
190
 
125
191
  if client.settings.request_identity_mode != "assertion":
@@ -130,16 +196,19 @@ async def require_session_access(
130
196
  if caller.is_organization_admin:
131
197
  return None
132
198
  session = await client.get_session(session_uid)
133
- if session.created_by_user_uid != caller.uid:
134
- raise SessionAccessDeniedError()
135
- return session
199
+ if session.created_by_user_uid == caller.uid:
200
+ return session
201
+ if await _delegated_to_caller(client, session, caller):
202
+ return session
203
+ raise SessionAccessDeniedError()
136
204
 
137
205
 
138
206
  async def sessions_the_caller_may_address(
139
207
  client: MainSequenceClient,
140
208
  session_uids: Iterable[str],
141
209
  ) -> set[str] | None:
142
- """Return which of ``session_uids`` the current caller may address.
210
+ """Return which of ``session_uids`` the current caller may address, by the rule of
211
+ ``require_session_access()``.
143
212
 
144
213
  None means every session: outside hosted mode, or for an Organization admin. A session that
145
214
  no longer exists is left out.
@@ -160,7 +229,9 @@ async def sessions_the_caller_may_address(
160
229
  session = await client.get_session(session_uid)
161
230
  except SessionNotFoundError:
162
231
  continue
163
- if session.created_by_user_uid == caller.uid:
232
+ if session.created_by_user_uid == caller.uid or await _delegated_to_caller(
233
+ client, session, caller
234
+ ):
164
235
  allowed.add(session_uid)
165
236
  return allowed
166
237
 
@@ -240,9 +311,11 @@ class RequestIdentityMiddleware:
240
311
  token=token,
241
312
  )
242
313
  caller_token = _current_caller.set(caller)
314
+ lookups_token = _workload_lookups.set({})
243
315
  try:
244
316
  await self.app(scope, receive, send)
245
317
  finally:
318
+ _workload_lookups.reset(lookups_token)
246
319
  _current_caller.reset(caller_token)
247
320
  scope.pop(_CALLER_ASSERTION_SCOPE_KEY, None)
248
321
 
@@ -113,6 +113,9 @@ class CallerAssertion:
113
113
  caller: VerifiedCaller
114
114
  expires_at: int
115
115
  token: str = field(repr=False)
116
+ # True when the platform's directory showed the caller to be a workload User. A workload is
117
+ # never a turn's or a Task's requester.
118
+ caller_is_workload: bool = False
116
119
 
117
120
  def unexpired_token(self, *, now: float | None = None) -> str | None:
118
121
  """Return the raw assertion while it is valid, and None once it has expired."""
@@ -35,6 +35,7 @@ from .models import (
35
35
  AgentTaskEventPage,
36
36
  AgentTaskExecutionAttempt,
37
37
  AgentTaskSnapshot,
38
+ DirectoryUser,
38
39
  LocalChatSessionPage,
39
40
  LocalChatTranscript,
40
41
  LocalConversationMessagePage,
@@ -77,6 +78,7 @@ from .routes import (
77
78
  agent_task_operation,
78
79
  model_provider_credentials,
79
80
  resource_release_runtime_access,
81
+ user,
80
82
  )
81
83
 
82
84
  T = TypeVar("T")
@@ -404,6 +406,12 @@ class MainSequenceClient:
404
406
  detail=error.errors(include_input=False),
405
407
  ) from error
406
408
 
409
+ async def get_user(self, user_uid: str) -> DirectoryUser:
410
+ """Look a User of the Organization up, as the platform's directory shows it."""
411
+
412
+ data = await self._request("GET", user(user_uid), idempotent=True)
413
+ return DirectoryUser.model_validate(data)
414
+
407
415
  async def list_model_providers(
408
416
  self, *, organization_environment_uid: str | None = None
409
417
  ) -> dict[str, Any]:
@@ -74,6 +74,9 @@ class AgentSession(BackendModel):
74
74
  )
75
75
  status: str | None = None
76
76
  created_by_user_uid: str | None = None
77
+ # The Agent of the parent session, or None for a session without a parent. The Agent that
78
+ # delegated to a child session addresses it with its own workload User.
79
+ parent_session_agent_uid: str | None = None
77
80
  runtime_capabilities: dict[str, str] = Field(default_factory=dict)
78
81
 
79
82
  @model_validator(mode="after")
@@ -87,6 +90,18 @@ class AgentSession(BackendModel):
87
90
  return self
88
91
 
89
92
 
93
+ class DirectoryUser(BackendModel):
94
+ """A User of the Organization as the platform's directory shows it to the runtime.
95
+
96
+ A workload User's row carries ``identity_type`` ``workload`` and, for the workload of an
97
+ Agent's release, ``agent_uid``.
98
+ """
99
+
100
+ uid: str
101
+ identity_type: str | None = None
102
+ agent_uid: str | None = None
103
+
104
+
90
105
  class AgentCardEnvelope(BackendModel):
91
106
  agent_session_uid: str
92
107
  agent_uid: str
@@ -12,6 +12,7 @@ MODEL_PROVIDER_CREDENTIALS = f"{API_V1_PREFIX}/model-provider-credentials/"
12
12
  MODEL_PROVIDERS = f"{API_V1_PREFIX}/model-providers/"
13
13
  AGENT_TASKS = f"{API_V1_PREFIX}/agent-tasks/"
14
14
  RESOURCE_RELEASES = f"{API_V1_PREFIX}/resource-releases/"
15
+ USERS = f"{API_V1_PREFIX}/users/"
15
16
 
16
17
  type RuntimeLeaseOperation = Literal["acquire", "renew", "release"]
17
18
  type ModelProviderCredentialOperation = Literal["hydrate", "status", "flush", "revoke"]
@@ -92,3 +93,7 @@ def agent_task_operation(task_uid: str, operation: AgentTaskOperation) -> str:
92
93
 
93
94
  def resource_release_runtime_access(release_uid: str) -> str:
94
95
  return f"{RESOURCE_RELEASES}{release_uid}/resolve-runtime-access/"
96
+
97
+
98
+ def user(user_uid: str) -> str:
99
+ return f"{USERS}{user_uid}/"
@@ -1729,7 +1729,7 @@ class SessionRuntimeManager:
1729
1729
  return None
1730
1730
  if task_context is not None:
1731
1731
  return task_context.requester
1732
- if caller_assertion is None:
1732
+ if caller_assertion is None or caller_assertion.caller_is_workload:
1733
1733
  return None
1734
1734
  caller = caller_assertion.caller
1735
1735
  if runtime.requester_user_uid != caller.uid: