getstack 2.0.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {getstack-2.0.0 → getstack-3.0.0}/PKG-INFO +12 -5
  2. {getstack-2.0.0 → getstack-3.0.0}/README.md +11 -4
  3. {getstack-2.0.0 → getstack-3.0.0}/pyproject.toml +1 -1
  4. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/__init__.py +21 -6
  5. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/dropoffs.py +10 -2
  6. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/missions.py +23 -3
  7. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/partner_tenants.py +9 -1
  8. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/passport_session.py +14 -7
  9. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/passports.py +169 -59
  10. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/dropoffs.py +20 -4
  11. getstack-3.0.0/src/getstack/errors.py +145 -0
  12. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/llm.py +2 -1
  13. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/missions.py +77 -13
  14. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/partner_tenants.py +11 -1
  15. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/passport_session.py +19 -9
  16. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/passports.py +297 -68
  17. getstack-3.0.0/tests/test_dropoffs.py +117 -0
  18. getstack-3.0.0/tests/test_mission_binding.py +339 -0
  19. {getstack-2.0.0 → getstack-3.0.0}/tests/test_parity.py +52 -4
  20. getstack-2.0.0/src/getstack/errors.py +0 -79
  21. getstack-2.0.0/tests/test_dropoffs.py +0 -70
  22. {getstack-2.0.0 → getstack-3.0.0}/.gitignore +0 -0
  23. {getstack-2.0.0 → getstack-3.0.0}/CLAUDE.md +0 -0
  24. {getstack-2.0.0 → getstack-3.0.0}/LICENSE +0 -0
  25. {getstack-2.0.0 → getstack-3.0.0}/scripts/verify_sign_claim_crosslang.py +0 -0
  26. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/__init__.py +0 -0
  27. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/agents.py +0 -0
  28. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/audit.py +0 -0
  29. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/authority.py +0 -0
  30. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/credentials.py +0 -0
  31. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/delivery_methods.py +0 -0
  32. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/detector_config.py +0 -0
  33. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/evidence_packs.py +0 -0
  34. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/fleets.py +0 -0
  35. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/identity.py +0 -0
  36. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/inbound_webhooks.py +0 -0
  37. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/intents.py +0 -0
  38. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/llm.py +0 -0
  39. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/notifications.py +0 -0
  40. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/partner_connect.py +0 -0
  41. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/pii_config.py +0 -0
  42. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/proxy.py +0 -0
  43. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/reviews.py +0 -0
  44. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/scan.py +0 -0
  45. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/security_events.py +0 -0
  46. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/services.py +0 -0
  47. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/skills.py +0 -0
  48. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/team.py +0 -0
  49. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_async/usage.py +0 -0
  50. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_authority_request.py +0 -0
  51. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/_fleet_key.py +0 -0
  52. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/agent_auth.py +0 -0
  53. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/agents.py +0 -0
  54. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/audit.py +0 -0
  55. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/auth.py +0 -0
  56. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/authority.py +0 -0
  57. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/browser_bootstrap.py +0 -0
  58. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/client.py +0 -0
  59. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/credentials.py +0 -0
  60. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/delivery_methods.py +0 -0
  61. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/detector_config.py +0 -0
  62. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/evidence_packs.py +0 -0
  63. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/fleets.py +0 -0
  64. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/identity.py +0 -0
  65. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/inbound_webhooks.py +0 -0
  66. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/intents.py +0 -0
  67. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/notifications.py +0 -0
  68. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/partner_connect.py +0 -0
  69. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/passport_header_verify.py +0 -0
  70. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/pii_config.py +0 -0
  71. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/proxy.py +0 -0
  72. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/py.typed +0 -0
  73. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/reviews.py +0 -0
  74. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/scan.py +0 -0
  75. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/security_events.py +0 -0
  76. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/services.py +0 -0
  77. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/sign_claim.py +0 -0
  78. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/skills.py +0 -0
  79. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/team.py +0 -0
  80. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/types.py +0 -0
  81. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/usage.py +0 -0
  82. {getstack-2.0.0 → getstack-3.0.0}/src/getstack/verify_offline.py +0 -0
  83. {getstack-2.0.0 → getstack-3.0.0}/tests/test_authority.py +0 -0
  84. {getstack-2.0.0 → getstack-3.0.0}/tests/test_delivery_methods.py +0 -0
  85. {getstack-2.0.0 → getstack-3.0.0}/tests/test_errors.py +0 -0
  86. {getstack-2.0.0 → getstack-3.0.0}/tests/test_evidence_packs.py +0 -0
  87. {getstack-2.0.0 → getstack-3.0.0}/tests/test_fleets.py +0 -0
  88. {getstack-2.0.0 → getstack-3.0.0}/tests/test_identity_requirements.py +0 -0
  89. {getstack-2.0.0 → getstack-3.0.0}/tests/test_inbound_webhooks.py +0 -0
  90. {getstack-2.0.0 → getstack-3.0.0}/tests/test_missions.py +0 -0
  91. {getstack-2.0.0 → getstack-3.0.0}/tests/test_partner_signing.py +0 -0
  92. {getstack-2.0.0 → getstack-3.0.0}/tests/test_sign_claim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: getstack
3
- Version: 2.0.0
3
+ Version: 3.0.0
4
4
  Summary: Python SDK for STACK — trust infrastructure for AI agents
5
5
  Project-URL: Homepage, https://getstack.run
6
6
  Project-URL: Documentation, https://getstack.run/docs/sdk
@@ -125,8 +125,15 @@ stack.agents.list()
125
125
  stack.agents.update(agent_id, **fields)
126
126
  stack.agents.unblock(agent_id)
127
127
 
128
+ # Missions (every passport is issued for exactly one Mission)
129
+ stack.missions.open(intent_summary, agent_id=None, intent_services=None, ...)
130
+ stack.missions.start(agent_id=agent_id, mission={"intent_summary": "..."}, passport=None)
131
+ stack.missions.complete(mission_id) # also revokes the Mission's passports
132
+ stack.missions.revoke(mission_id, reason="operator_initiated_clean")
133
+
128
134
  # Passports
129
- stack.passports.issue(agent_id, intent=None, services=None, ...)
135
+ stack.passports.issue(agent_id, mission_ids=[mission_id], intent=None, services=None, ...)
136
+ stack.passports.mission(agent_id, intent, services, ...) # context manager: Mission + passport, closed on exit
130
137
  stack.passports.verify(token)
131
138
  stack.passports.revoke(jti, reason=None)
132
139
  stack.passports.checkpoint(jti, services_used, actions_count, ...)
@@ -148,9 +155,9 @@ stack.reviews.list(status="flagged")
148
155
  stack.reviews.decide(checkout_id, decision, notes=None, block_future=False)
149
156
 
150
157
  # Drop-offs
151
- stack.dropoffs.create(from_agent_id, to_agent_id, schema, ...)
152
- stack.dropoffs.deposit(dropoff_id, data, agent_id)
153
- stack.dropoffs.collect(dropoff_id, agent_id)
158
+ stack.dropoffs.create(from_agent_id, to_agent_id, schema, ..., passport_token=None)
159
+ stack.dropoffs.deposit(dropoff_id, data, agent_id, passport_token=None)
160
+ stack.dropoffs.collect(dropoff_id, agent_id, passport_token=None)
154
161
 
155
162
  # Notifications
156
163
  stack.notifications.list()
@@ -98,8 +98,15 @@ stack.agents.list()
98
98
  stack.agents.update(agent_id, **fields)
99
99
  stack.agents.unblock(agent_id)
100
100
 
101
+ # Missions (every passport is issued for exactly one Mission)
102
+ stack.missions.open(intent_summary, agent_id=None, intent_services=None, ...)
103
+ stack.missions.start(agent_id=agent_id, mission={"intent_summary": "..."}, passport=None)
104
+ stack.missions.complete(mission_id) # also revokes the Mission's passports
105
+ stack.missions.revoke(mission_id, reason="operator_initiated_clean")
106
+
101
107
  # Passports
102
- stack.passports.issue(agent_id, intent=None, services=None, ...)
108
+ stack.passports.issue(agent_id, mission_ids=[mission_id], intent=None, services=None, ...)
109
+ stack.passports.mission(agent_id, intent, services, ...) # context manager: Mission + passport, closed on exit
103
110
  stack.passports.verify(token)
104
111
  stack.passports.revoke(jti, reason=None)
105
112
  stack.passports.checkpoint(jti, services_used, actions_count, ...)
@@ -121,9 +128,9 @@ stack.reviews.list(status="flagged")
121
128
  stack.reviews.decide(checkout_id, decision, notes=None, block_future=False)
122
129
 
123
130
  # Drop-offs
124
- stack.dropoffs.create(from_agent_id, to_agent_id, schema, ...)
125
- stack.dropoffs.deposit(dropoff_id, data, agent_id)
126
- stack.dropoffs.collect(dropoff_id, agent_id)
131
+ stack.dropoffs.create(from_agent_id, to_agent_id, schema, ..., passport_token=None)
132
+ stack.dropoffs.deposit(dropoff_id, data, agent_id, passport_token=None)
133
+ stack.dropoffs.collect(dropoff_id, agent_id, passport_token=None)
127
134
 
128
135
  # Notifications
129
136
  stack.notifications.list()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "2.0.0"
7
+ version = "3.0.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -52,7 +52,7 @@ from .auth import ApiKeyAuth, SessionAuth, OAuthAuth, CredentialsFileAuth
52
52
  from .agent_auth import AgentKeypairAuth
53
53
  from .client import HttpClient, AsyncHttpClient, DEFAULT_BASE_URL
54
54
  from .agents import AgentService
55
- from .passports import PassportService, Mission
55
+ from .passports import PassportService, Mission, _require_one_mission
56
56
  from .credentials import CredentialService
57
57
  from .services import ServiceService
58
58
  from .dropoffs import DropoffService
@@ -119,6 +119,7 @@ from .errors import (
119
119
  ForbiddenError,
120
120
  ValidationError,
121
121
  PassportBlockedError,
122
+ MissionEndedError,
122
123
  )
123
124
 
124
125
  try:
@@ -222,6 +223,7 @@ __all__ = [
222
223
  "ForbiddenError",
223
224
  "ValidationError",
224
225
  "PassportBlockedError",
226
+ "MissionEndedError",
225
227
  ]
226
228
 
227
229
 
@@ -301,18 +303,25 @@ class Stack:
301
303
  ttl_seconds: int | None = None,
302
304
  accountability_mode: str | None = None,
303
305
  authority_binding_ids: list[str] | None = None,
306
+ mission_ids: list[str] | None = None,
304
307
  **kwargs: Any,
305
308
  ) -> PassportSession:
306
309
  """Phase 5.2.6 — issue a passport and return a PassportSession
307
310
  that holds the token + auto-refreshes ~60s before expiry. Pass
308
311
  the returned session to LLM calls instead of threading a raw
309
- token per call::
312
+ token per call. ``mission_ids`` must hold exactly one Mission id;
313
+ once that Mission ends, refresh raises ``MissionEndedError``::
310
314
 
311
- session = stack.issue_passport(agent_id="agt_x", services=[...])
315
+ mission = stack.missions.open("Answer support tickets", agent_id="agt_x")
316
+ session = stack.issue_passport(agent_id="agt_x", mission_ids=[mission["mission_id"]])
312
317
  for i in range(1000):
313
318
  stack.llm.anthropic.messages.create(body, passport=session)
314
319
  """
315
- issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
320
+ issue_input: dict[str, Any] = {
321
+ "agent_id": agent_id,
322
+ **kwargs,
323
+ "mission_ids": _require_one_mission(mission_ids),
324
+ }
316
325
  if services is not None: issue_input["services"] = services
317
326
  if intents is not None: issue_input["intents"] = intents
318
327
  if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
@@ -483,10 +492,16 @@ class AsyncStack:
483
492
  ttl_seconds: int | None = None,
484
493
  accountability_mode: str | None = None,
485
494
  authority_binding_ids: list[str] | None = None,
495
+ mission_ids: list[str] | None = None,
486
496
  **kwargs: Any,
487
497
  ):
488
- """Phase 5.2.6 — async passport issue + session wrapper."""
489
- issue_input: dict[str, Any] = {"agent_id": agent_id, **kwargs}
498
+ """Phase 5.2.6 — async passport issue + session wrapper.
499
+ ``mission_ids`` must hold exactly one Mission id."""
500
+ issue_input: dict[str, Any] = {
501
+ "agent_id": agent_id,
502
+ **kwargs,
503
+ "mission_ids": _require_one_mission(mission_ids),
504
+ }
490
505
  if services is not None: issue_input["services"] = services
491
506
  if intents is not None: issue_input["intents"] = intents
492
507
  if ttl_seconds is not None: issue_input["ttl_seconds"] = ttl_seconds
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from ..client import AsyncHttpClient
8
+ from ..dropoffs import _passport_header
8
9
 
9
10
 
10
11
  class AsyncDropoffService:
@@ -18,7 +19,9 @@ class AsyncDropoffService:
18
19
  schema: dict[str, Any],
19
20
  ttl_seconds: int | None = None,
20
21
  on_expire: str | None = None,
22
+ passport_token: str | None = None,
21
23
  ) -> dict[str, Any]:
24
+ """Create a drop-off. An agent caller passes the producer's Mission Passport as passport_token."""
22
25
  body: dict[str, Any] = {
23
26
  "from_agent": from_agent_id,
24
27
  "to_agent": to_agent_id,
@@ -28,7 +31,7 @@ class AsyncDropoffService:
28
31
  body["ttl_seconds"] = ttl_seconds
29
32
  if on_expire:
30
33
  body["on_expire"] = on_expire
31
- return await self._client.post("/v1/dropoffs", json=body)
34
+ return await self._client.post("/v1/dropoffs", json=body, extra_headers=_passport_header(passport_token))
32
35
 
33
36
  async def deposit(
34
37
  self,
@@ -37,7 +40,9 @@ class AsyncDropoffService:
37
40
  agent_id: str,
38
41
  passport_jti: str | None = None,
39
42
  mission_id: str | None = None,
43
+ passport_token: str | None = None,
40
44
  ) -> dict[str, Any]:
45
+ """Deposit as the producer. An agent caller passes its Mission Passport as passport_token (or passport_jti)."""
41
46
  body: dict[str, Any] = {"agent_id": agent_id, "payload": data}
42
47
  if passport_jti is not None:
43
48
  body["passport_jti"] = passport_jti
@@ -46,12 +51,15 @@ class AsyncDropoffService:
46
51
  return await self._client.post(
47
52
  f"/v1/dropoffs/{dropoff_id}/deposit",
48
53
  json=body,
54
+ extra_headers=_passport_header(passport_token),
49
55
  )
50
56
 
51
- async def collect(self, dropoff_id: str, agent_id: str) -> dict[str, Any]:
57
+ async def collect(self, dropoff_id: str, agent_id: str, passport_token: str | None = None) -> dict[str, Any]:
58
+ """Collect as the consumer. An agent caller passes its Mission Passport as passport_token."""
52
59
  return await self._client.post(
53
60
  f"/v1/dropoffs/{dropoff_id}/collect",
54
61
  json={"agent_id": agent_id},
62
+ extra_headers=_passport_header(passport_token),
55
63
  )
56
64
 
57
65
  async def expire(self, dropoff_id: str) -> dict[str, Any]:
@@ -1,4 +1,8 @@
1
- """Async mirror of MissionsService. Same surface, async/await."""
1
+ """Async mirror of MissionsService. Same surface, async/await.
2
+
3
+ Completing or revoking a Mission (any reason) revokes every passport
4
+ bound to it.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
@@ -6,14 +10,26 @@ from typing import Any
6
10
  from urllib.parse import quote
7
11
 
8
12
  from ..client import AsyncHttpClient
13
+ from ..missions import _start_body
9
14
 
10
15
 
11
16
  class AsyncMissionsService:
12
17
  def __init__(self, client: AsyncHttpClient):
13
18
  self._client = client
14
19
 
15
- async def start(self, request: dict[str, Any]) -> dict[str, Any]:
16
- return await self._client.request("POST", "/v1/missions/start", json=request)
20
+ async def start(
21
+ self,
22
+ request: dict[str, Any] | None = None,
23
+ *,
24
+ agent_id: str | None = None,
25
+ mission: dict[str, Any] | None = None,
26
+ passport: dict[str, Any] | None = None,
27
+ ) -> dict[str, Any]:
28
+ """Open a Mission and issue its passport in one call. Operator form:
29
+ ``await stack.missions.start(agent_id=..., mission={...}, passport={...})``.
30
+ Fleet form (unchanged): ``await stack.missions.start({"fleet_policy_id": ...})``."""
31
+ body = _start_body(request, agent_id, mission, passport)
32
+ return await self._client.request("POST", "/v1/missions/start", json=body)
17
33
 
18
34
  async def renew(self, mission_id: str) -> dict[str, Any]:
19
35
  return await self._client.request("POST", f"/v1/missions/{quote(mission_id, safe='')}/renew", json={})
@@ -41,8 +57,12 @@ class AsyncMissionsService:
41
57
  requested_operations: list[dict[str, Any]] | None = None,
42
58
  limits: dict[str, Any] | None = None,
43
59
  authority_binding_ids: list[str] | None = None,
60
+ agent_id: str | None = None,
44
61
  ) -> dict[str, Any]:
62
+ """Open a new mission. ``agent_id`` assigns it to that agent."""
45
63
  body: dict[str, Any] = {"intent_summary": intent_summary}
64
+ if agent_id is not None:
65
+ body["agent_id"] = agent_id
46
66
  if intent_services is not None:
47
67
  body["intent_services"] = intent_services
48
68
  if expected_counterparties is not None:
@@ -6,6 +6,7 @@ from typing import Any, Literal, Optional
6
6
  from urllib.parse import quote
7
7
 
8
8
  from ..client import AsyncHttpClient
9
+ from ..passports import _require_one_mission
9
10
 
10
11
 
11
12
 
@@ -87,8 +88,12 @@ class AsyncPartnerTenantsService:
87
88
  services: Optional[list[dict[str, Any]]] = None,
88
89
  named_intents: Optional[list[str]] = None,
89
90
  accountability_mode: Optional[Literal["enforced", "logged", "standard"]] = None,
91
+ mission_ids: Optional[list[str]] = None,
90
92
  ) -> dict[str, Any]:
91
- body: dict[str, Any] = {"agent_id": agent_id}
93
+ """Issue a passport under the tenant. ``mission_ids`` must hold exactly
94
+ one Mission id the tenant's agent may use; anything else raises
95
+ ``ValueError`` before any request."""
96
+ body: dict[str, Any] = {"agent_id": agent_id, "mission_ids": _require_one_mission(mission_ids)}
92
97
  if ttl_seconds is not None:
93
98
  body["ttl_seconds"] = ttl_seconds
94
99
  if services is not None:
@@ -110,6 +115,8 @@ class AsyncPartnerTenantsService:
110
115
  ttl_seconds: int = 900,
111
116
  accountability_mode: Literal["enforced", "logged", "standard"] = "enforced",
112
117
  named_intents: Optional[list[str]] = None,
118
+ *,
119
+ mission_id: str,
113
120
  ) -> dict[str, Any]:
114
121
  """Phase 5.7 — convenience helper for the MIME-stamping pattern.
115
122
 
@@ -123,6 +130,7 @@ class AsyncPartnerTenantsService:
123
130
  accountability_mode=accountability_mode,
124
131
  services=services,
125
132
  named_intents=named_intents,
133
+ mission_ids=[mission_id],
126
134
  )
127
135
  token = issued.get("token") or issued.get("passport")
128
136
  passport_id = issued.get("passport_id") or issued.get("jti")
@@ -6,6 +6,7 @@ import time
6
6
  from typing import Any
7
7
 
8
8
  from ..client import AsyncHttpClient
9
+ from ..errors import StackError, mission_ended_error
9
10
  from ..passport_session import _decode_jwt_exp
10
11
 
11
12
 
@@ -25,13 +26,19 @@ class AsyncPassportSession:
25
26
  now_sec = int(time.time())
26
27
  if self._exp_seconds - now_sec > grace_seconds:
27
28
  return self._current_token
28
- refreshed = await self._client.post(
29
- "/v1/passports/refresh",
30
- json={
31
- "token": self._current_token,
32
- "ttl_seconds": self._issue_input.get("ttl_seconds"),
33
- },
34
- )
29
+ try:
30
+ refreshed = await self._client.post(
31
+ "/v1/passports/refresh",
32
+ json={
33
+ "token": self._current_token,
34
+ "ttl_seconds": self._issue_input.get("ttl_seconds"),
35
+ },
36
+ )
37
+ except StackError as exc:
38
+ ended = mission_ended_error(exc)
39
+ if ended is not None:
40
+ raise ended from exc
41
+ raise
35
42
  self._current_token = refreshed["token"]
36
43
  self._exp_seconds = _decode_jwt_exp(self._current_token)
37
44
  return self._current_token
@@ -13,8 +13,20 @@ import asyncio
13
13
  from contextlib import asynccontextmanager
14
14
  from typing import Any, AsyncGenerator, Awaitable, Callable
15
15
 
16
+ from urllib.parse import quote
17
+
16
18
  from ..client import AsyncHttpClient
17
- from ..passports import _parse_duration, _to_passport, _delegation_scopes
19
+ from ..errors import StackError, mission_ended_error
20
+ from ..passports import (
21
+ _delegation_scopes,
22
+ _intent_body,
23
+ _parse_duration,
24
+ _require_one_mission,
25
+ _revoke_notes,
26
+ _signed_accountability,
27
+ _start_mission_body,
28
+ _to_passport,
29
+ )
18
30
  from ..proxy import ProxyResponse
19
31
  from .proxy import AsyncProxyService
20
32
  from ..types import Passport, CheckpointResult, CheckoutResult, PassportReport, ToolCall
@@ -35,6 +47,8 @@ class AsyncMission:
35
47
  will_delegate: bool = False,
36
48
  estimated_duration: str | None = None,
37
49
  checkpoint_interval: str | None = None,
50
+ mission_id: str | None = None,
51
+ expires_in: str | None = None,
38
52
  ):
39
53
  self._service = service
40
54
  self._agent_id = agent_id
@@ -43,6 +57,10 @@ class AsyncMission:
43
57
  self._will_delegate = will_delegate
44
58
  self._estimated_duration = estimated_duration
45
59
  self._checkpoint_interval = checkpoint_interval
60
+ self._expires_in = expires_in
61
+
62
+ self.mission_id: str | None = mission_id
63
+ self._owns_mission: bool = False
46
64
 
47
65
  self.passport: Passport | None = None
48
66
  self.review_status: str | None = None
@@ -112,14 +130,30 @@ class AsyncMission:
112
130
  return resp
113
131
 
114
132
  async def _enter(self) -> None:
115
- self.passport = await self._service.issue(
116
- agent_id=self._agent_id,
117
- intent=self._intent,
118
- services=self._declared_services,
119
- will_delegate=self._will_delegate,
120
- estimated_duration=self._estimated_duration,
121
- checkpoint_interval=self._checkpoint_interval,
122
- )
133
+ if self.mission_id is None:
134
+ # One call opens the Mission and issues its passport; the API
135
+ # revokes the Mission if issuance fails.
136
+ started = await self._service._start_mission(_start_mission_body(
137
+ self._agent_id, self._intent, self._declared_services,
138
+ self._will_delegate, self._estimated_duration,
139
+ self._checkpoint_interval, self._expires_in,
140
+ ))
141
+ self.mission_id = started["mission_id"]
142
+ self._owns_mission = True
143
+ passport = _to_passport(started["passport"])
144
+ if passport.accountability is None:
145
+ passport.accountability = _signed_accountability(passport.token)
146
+ self.passport = passport
147
+ else:
148
+ self.passport = await self._service.issue(
149
+ agent_id=self._agent_id,
150
+ intent=self._intent,
151
+ services=self._declared_services,
152
+ will_delegate=self._will_delegate,
153
+ estimated_duration=self._estimated_duration,
154
+ checkpoint_interval=self._checkpoint_interval,
155
+ mission_ids=[self.mission_id],
156
+ )
123
157
  self._rotation_start = asyncio.get_event_loop().time()
124
158
 
125
159
  if self._checkpoint_interval:
@@ -162,7 +196,9 @@ class AsyncMission:
162
196
  )
163
197
  self._last_checkpoint_index = len(self._tool_calls)
164
198
 
165
- async def _exit(self, error: Exception | None = None) -> None:
199
+ async def _exit(self, error: BaseException | None = None) -> None:
200
+ """Checkout first (closing the Mission revokes its passports), then
201
+ close the Mission this object opened."""
166
202
  if self._task and not self._task.done():
167
203
  self._task.cancel()
168
204
  try:
@@ -171,32 +207,33 @@ class AsyncMission:
171
207
  pass
172
208
  self._task = None
173
209
 
174
- if not self.passport:
175
- return
176
-
177
- summary = None
178
- if error:
179
- summary = f"Mission failed: {type(error).__name__}: {error}"
210
+ if self.passport:
211
+ summary = None
212
+ if error:
213
+ summary = f"Mission failed: {type(error).__name__}: {error}"
180
214
 
181
- try:
182
- self.result = await self._service.checkout(
183
- self.passport.jti,
184
- services_used=list(self._services_used),
185
- actions_count=len(self._tool_calls),
186
- tool_calls=[tc.to_dict() for tc in self._tool_calls] if self._tool_calls else None,
187
- summary=summary,
188
- delegated_to=self._delegated_to if self._delegated_to else None,
189
- )
190
- self.review_status = self.result.review_status
191
- except Exception:
192
- pass # Best-effort checkout
215
+ try:
216
+ self.result = await self._service.checkout(
217
+ self.passport.jti,
218
+ services_used=list(self._services_used),
219
+ actions_count=len(self._tool_calls),
220
+ tool_calls=[tc.to_dict() for tc in self._tool_calls] if self._tool_calls else None,
221
+ summary=summary,
222
+ delegated_to=self._delegated_to if self._delegated_to else None,
223
+ )
224
+ self.review_status = self.result.review_status
225
+ except Exception:
226
+ pass # Best-effort checkout
227
+
228
+ if self._owns_mission and self.mission_id:
229
+ await self._service._finish_mission(self.mission_id, error)
193
230
 
194
231
  async def __aenter__(self) -> AsyncMission:
195
232
  await self._enter()
196
233
  return self
197
234
 
198
235
  async def __aexit__(
199
- self, exc_type: type | None, exc_val: Exception | None, exc_tb: Any
236
+ self, exc_type: type | None, exc_val: BaseException | None, exc_tb: Any
200
237
  ) -> bool:
201
238
  await self._exit(error=exc_val if exc_type else None)
202
239
  return False
@@ -221,6 +258,17 @@ class AsyncPassportService:
221
258
  ) -> Passport:
222
259
  """Issue a signed passport for an agent (async mirror of ``Stack``).
223
260
 
261
+ ``mission_ids`` must hold exactly one live Mission id (``ms_*``);
262
+ anything else raises ``ValueError`` before any request::
263
+
264
+ mission = await stack.missions.open("Send the weekly report", agent_id="agt_x")
265
+ passport = await stack.passports.issue(
266
+ agent_id="agt_x",
267
+ mission_ids=[mission["mission_id"]],
268
+ intent="Send the weekly report",
269
+ services=["resend"],
270
+ )
271
+
224
272
  ``scopes`` is a list of per-connection override dicts. Each recognises:
225
273
 
226
274
  - ``service_connection_id`` (str) — the connection to scope.
@@ -235,6 +283,7 @@ class AsyncPassportService:
235
283
 
236
284
  await stack.passports.issue(
237
285
  agent_id="agt_x",
286
+ mission_ids=[mission_id],
238
287
  scopes=[{
239
288
  "service_connection_id": "scon_resend",
240
289
  "scopes": ["email:send"],
@@ -242,26 +291,44 @@ class AsyncPassportService:
242
291
  }],
243
292
  )
244
293
  """
245
- body: dict[str, Any] = {"agent_id": agent_id}
294
+ body: dict[str, Any] = {"agent_id": agent_id, "mission_ids": _require_one_mission(mission_ids)}
246
295
  if scopes:
247
296
  body["scopes"] = scopes
248
297
  if authority_binding_ids is not None:
249
298
  body["authority_binding_ids"] = authority_binding_ids
250
- if mission_ids is not None:
251
- body["mission_ids"] = mission_ids
252
299
  if ttl_seconds:
253
300
  body["ttl_seconds"] = ttl_seconds
254
- if intent or services:
255
- body["intent"] = {
256
- "summary": intent or "",
257
- "services": services or [],
258
- "will_delegate": will_delegate,
259
- }
260
- if estimated_duration:
261
- body["intent"]["estimated_duration_seconds"] = _parse_duration(estimated_duration)
301
+ intent_body = _intent_body(intent, services, will_delegate, estimated_duration)
302
+ if intent_body is not None:
303
+ body["intent"] = intent_body
262
304
  if checkpoint_interval:
263
305
  body["checkpoint_interval_seconds"] = _parse_duration(checkpoint_interval)
264
- return _to_passport(await self._client.post("/v1/passports/issue", json=body))
306
+ try:
307
+ data = await self._client.post("/v1/passports/issue", json=body)
308
+ except StackError as exc:
309
+ ended = mission_ended_error(exc)
310
+ if ended is not None:
311
+ raise ended from exc
312
+ raise
313
+ return _to_passport(data)
314
+
315
+ async def _start_mission(self, body: dict[str, Any]) -> dict[str, Any]:
316
+ return await self._client.post("/v1/missions/start", json=body)
317
+
318
+ async def _finish_mission(self, mission_id: str, error: BaseException | None) -> None:
319
+ """Complete the Mission, or revoke it when the block raised. Mirror of
320
+ the sync PassportService._finish_mission."""
321
+ path_id = quote(mission_id, safe="")
322
+ if error is None:
323
+ await self._client.post(f"/v1/missions/{path_id}/complete", json={})
324
+ return
325
+ try:
326
+ await self._client.post(
327
+ f"/v1/missions/{path_id}/revoke",
328
+ json={"reason": "operator_initiated_clean", "notes": _revoke_notes(error)},
329
+ )
330
+ except Exception:
331
+ pass
265
332
 
266
333
  async def checkpoint(
267
334
  self,
@@ -362,10 +429,18 @@ class AsyncPassportService:
362
429
  return await self._client.get("/v1/passports/active", params=params or None)
363
430
 
364
431
  async def refresh(self, token: str, ttl_seconds: int | None = None) -> Passport:
432
+ """Refresh within the Mission; raises MissionEndedError once it ends."""
365
433
  body: dict[str, Any] = {"token": token}
366
434
  if ttl_seconds:
367
435
  body["ttl_seconds"] = ttl_seconds
368
- return _to_passport(await self._client.post("/v1/passports/refresh", json=body))
436
+ try:
437
+ data = await self._client.post("/v1/passports/refresh", json=body)
438
+ except StackError as exc:
439
+ ended = mission_ended_error(exc)
440
+ if ended is not None:
441
+ raise ended from exc
442
+ raise
443
+ return _to_passport(data)
369
444
 
370
445
  async def list_active(
371
446
  self, agent_id: str | None = None, session_id: str | None = None
@@ -386,18 +461,26 @@ class AsyncPassportService:
386
461
  will_delegate: bool = False,
387
462
  estimated_duration: str | None = None,
388
463
  checkpoint_interval: str | None = None,
464
+ mission_id: str | None = None,
465
+ expires_in: str | None = None,
389
466
  ) -> AsyncGenerator[AsyncMission, None]:
390
- """Async-context-manager mission. Mirror of sync mission()."""
467
+ """Async-context-manager mission. Mirror of sync mission(): opens the
468
+ Mission and its passport in one call (POST /v1/missions/start),
469
+ checks out, then completes the Mission on exit (revokes it if the
470
+ block raised). ``mission_id`` binds an existing Mission, which is
471
+ then left open on exit."""
391
472
  m = AsyncMission(
392
473
  self, agent_id, intent, services,
393
474
  will_delegate=will_delegate,
394
475
  estimated_duration=estimated_duration,
395
476
  checkpoint_interval=checkpoint_interval,
477
+ mission_id=mission_id,
478
+ expires_in=expires_in,
396
479
  )
397
480
  await m._enter()
398
481
  try:
399
482
  yield m
400
- except Exception as e:
483
+ except BaseException as e:
401
484
  await m._exit(error=e)
402
485
  raise
403
486
  else:
@@ -411,21 +494,48 @@ class AsyncPassportService:
411
494
  rotation_interval: str = "4h",
412
495
  will_delegate: bool = False,
413
496
  checkpoint_interval: str | None = None,
497
+ mission_id: str | None = None,
498
+ expires_in: str | None = None,
414
499
  ) -> AsyncGenerator[AsyncMission, None]:
415
- """Async generator yielding successive AsyncMission objects."""
500
+ """Async generator yielding successive AsyncMission objects. Mirror of
501
+ sync continuous_mission(): every rotation shares one Mission, and
502
+ closing the generator (``aclose()``) completes the Mission it opened."""
416
503
  rotation_secs = float(_parse_duration(rotation_interval))
417
504
  rotation_count = 0
418
- while True:
419
- m = AsyncMission(
420
- self, agent_id, intent, services,
421
- will_delegate=will_delegate,
422
- checkpoint_interval=checkpoint_interval,
423
- )
424
- m.rotation_count = rotation_count
425
- m._rotation_interval = rotation_secs
426
- await m._enter()
427
- try:
428
- yield m
429
- finally:
430
- await m._exit()
431
- rotation_count += 1
505
+ bound_mission_id = mission_id
506
+ owned_mission_id: str | None = None
507
+ error: BaseException | None = None
508
+ try:
509
+ while True:
510
+ m = AsyncMission(
511
+ self, agent_id, intent, services,
512
+ will_delegate=will_delegate,
513
+ checkpoint_interval=checkpoint_interval,
514
+ mission_id=bound_mission_id,
515
+ expires_in=expires_in,
516
+ )
517
+ m.rotation_count = rotation_count
518
+ m._rotation_interval = rotation_secs
519
+ await m._enter()
520
+ if m._owns_mission:
521
+ owned_mission_id = bound_mission_id = m.mission_id
522
+ m._owns_mission = False
523
+ try:
524
+ yield m
525
+ except GeneratorExit:
526
+ await m._exit()
527
+ raise
528
+ except BaseException as e:
529
+ await m._exit(error=e)
530
+ raise
531
+ else:
532
+ await m._exit()
533
+ rotation_count += 1
534
+ except GeneratorExit:
535
+ raise
536
+ except BaseException as e:
537
+ error = e
538
+ raise
539
+ finally:
540
+ if owned_mission_id is not None:
541
+ await self._finish_mission(owned_mission_id, error)