waypoint-sdk 0.1.0__py3-none-any.whl

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.
sdk/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ from .client import WaypointClient
2
+ from .exceptions import (
3
+ CheckpointError,
4
+ CheckpointNotFoundError,
5
+ ExecutionNotFoundError,
6
+ GatewayError,
7
+ WaypointClientError,
8
+ WaypointConnectionError,
9
+ WaypointError,
10
+ WaypointTimeoutError,
11
+ )
12
+ from .gateway import Waypoint, checkpoint
13
+ from .models import (
14
+ CheckpointResponse,
15
+ ExecutionHistory,
16
+ ExecutionInfo,
17
+ ExecutionResult,
18
+ ExecutionStep,
19
+ ReplayState,
20
+ ResumeState,
21
+ StepStatus,
22
+ )
23
+ from .session import WaypointSession
24
+
25
+ __all__ = [
26
+ "Waypoint",
27
+ "WaypointClient",
28
+ "WaypointSession",
29
+ "checkpoint",
30
+ "ExecutionInfo",
31
+ "WaypointError",
32
+ "GatewayError",
33
+ "CheckpointError",
34
+ "WaypointClientError",
35
+ "WaypointConnectionError",
36
+ "WaypointTimeoutError",
37
+ "ExecutionNotFoundError",
38
+ "CheckpointNotFoundError",
39
+ "ExecutionResult",
40
+ "CheckpointResponse",
41
+ "ExecutionHistory",
42
+ "ExecutionStep",
43
+ "ResumeState",
44
+ "ReplayState",
45
+ "StepStatus",
46
+ ]
sdk/client.py ADDED
@@ -0,0 +1,255 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ import httpx
7
+
8
+ from .exceptions import (
9
+ CheckpointNotFoundError,
10
+ ExecutionNotFoundError,
11
+ GatewayError,
12
+ WaypointClientError,
13
+ )
14
+ from .models import (
15
+ CheckpointResponse,
16
+ ExecutionHistory,
17
+ ExecutionInfo,
18
+ ExecutionStep,
19
+ ReplayState,
20
+ ResumeState,
21
+ )
22
+
23
+ log = logging.getLogger(f"sdk.{__name__}")
24
+
25
+
26
+ class WaypointClient:
27
+ """
28
+ Low-level HTTP client for the Waypoint API.
29
+
30
+ Manages reusable httpx clients (sync and async) with lazy initialisation,
31
+ thread-safety locks, and explicit close/aclose lifecycle.
32
+
33
+ Every public method maps to exactly one API endpoint.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ base_url: str,
40
+ agent_id: str,
41
+ timeout: float = 30.0,
42
+ api_key: str | None = None,
43
+ ) -> None:
44
+ self.base_url = base_url.rstrip("/")
45
+ self.agent_id = agent_id
46
+ self.timeout = timeout
47
+ self.api_key = api_key
48
+
49
+ self._sync_client: httpx.Client | None = None
50
+ self._async_client: httpx.AsyncClient | None = None
51
+
52
+ self._sync_lock = asyncio.Lock()
53
+ self._async_lock = asyncio.Lock()
54
+
55
+ # ── low-level HTTP helpers ──────────────────────────────────────────────────
56
+
57
+ def _auth_headers(self) -> dict[str, str]:
58
+ if self.api_key:
59
+ return {"X-WAYPOINT-API-KEY": f"{self.api_key}"}
60
+ return {}
61
+
62
+ def _http(self) -> httpx.Client:
63
+ if self._sync_client is None or self._sync_client.is_closed:
64
+ self._sync_client = httpx.Client(
65
+ base_url=self.base_url,
66
+ headers=self._auth_headers(),
67
+ timeout=self.timeout,
68
+ trust_env=False,
69
+ )
70
+ return self._sync_client
71
+
72
+ async def _ahttp(self) -> httpx.AsyncClient:
73
+ if self._async_client is None or self._async_client.is_closed:
74
+ self._async_client = httpx.AsyncClient(
75
+ base_url=self.base_url,
76
+ headers=self._auth_headers(),
77
+ timeout=self.timeout,
78
+ trust_env=False,
79
+ )
80
+ return self._async_client
81
+
82
+ def close(self) -> None:
83
+ if self._sync_client:
84
+ self._sync_client.close()
85
+
86
+ async def aclose(self) -> None:
87
+ if self._async_client:
88
+ await self._async_client.aclose()
89
+
90
+ # ── request dispatch ────────────────────────────────────────────────────────
91
+
92
+ @staticmethod
93
+ def _build_path(
94
+ execution_id: UUID,
95
+ resource: str,
96
+ *,
97
+ include_load: bool = False,
98
+ ) -> str:
99
+ if resource == "create_execution":
100
+ return "/executions/"
101
+ if resource == "checkpoint":
102
+ return "/checkpoints/"
103
+ if resource == "checkpoint_load":
104
+ return "/checkpoints/load"
105
+ if resource == "history":
106
+ return f"/executions/{execution_id}/history"
107
+ if resource == "resume":
108
+ return f"/executions/{execution_id}/resume"
109
+ if resource == "replay":
110
+ return f"/executions/{execution_id}/replay"
111
+ msg = f"Unknown resource: {resource}"
112
+ raise ValueError(msg)
113
+
114
+ async def _arequest(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ json_body: dict[str, Any] | None = None,
119
+ params: dict[str, str | int] | None = None,
120
+ ) -> dict[str, Any]:
121
+ try:
122
+ http = await self._ahttp()
123
+ resp = await http.request(
124
+ method,
125
+ path,
126
+ json=json_body,
127
+ params=params,
128
+ )
129
+ body: dict[str, Any] = resp.json()
130
+ if not resp.is_success:
131
+ detail = body.get("detail", str(resp.reason_phrase))
132
+ if resp.status_code == 404:
133
+ msg = detail if isinstance(detail, str) else str(detail)
134
+ if "checkpoint" in msg.lower():
135
+ raise CheckpointNotFoundError(msg)
136
+ raise ExecutionNotFoundError(msg)
137
+ raise WaypointClientError(resp.status_code, detail)
138
+ return body
139
+ except httpx.ConnectError as exc:
140
+ raise GatewayError(self.base_url, exc) from exc
141
+ except httpx.TimeoutException as exc:
142
+ raise GatewayError(self.base_url, exc) from exc
143
+
144
+ # ── API methods ─────────────────────────────────────────────────────────────
145
+
146
+ async def create_execution(
147
+ self,
148
+ agent_id: str,
149
+ initial_input: dict[str, Any] | None = None,
150
+ ) -> ExecutionInfo:
151
+ body = await self._arequest(
152
+ "POST",
153
+ "/executions/",
154
+ json_body={
155
+ "agent_id": agent_id,
156
+ "initial_input": initial_input,
157
+ },
158
+ )
159
+ return ExecutionInfo(
160
+ id=UUID(body["id"]) if isinstance(body["id"], str) else body["id"],
161
+ agent_id=body["agent_id"],
162
+ status=body["status"],
163
+ started_at=body["started_at"],
164
+ initial_input=body.get("initial_input"),
165
+ created_at=body.get("created_at"),
166
+ )
167
+
168
+ async def create_checkpoint(
169
+ self,
170
+ execution_id: UUID,
171
+ step_number: int,
172
+ step_name: str,
173
+ state: dict[str, Any],
174
+ *,
175
+ input_data: dict[str, Any] | None = None,
176
+ output_data: dict[str, Any] | None = None,
177
+ status: str = "completed",
178
+ duration_ms: int | None = None,
179
+ error: dict[str, Any] | None = None,
180
+ cached: bool = False,
181
+ ) -> CheckpointResponse:
182
+ body = await self._arequest(
183
+ "POST",
184
+ "/checkpoints/",
185
+ json_body={
186
+ "execution_id": str(execution_id),
187
+ "step_number": step_number,
188
+ "step_name": step_name,
189
+ "state": state,
190
+ "input_data": input_data or {},
191
+ "output_data": output_data,
192
+ "status": status,
193
+ "duration_ms": duration_ms,
194
+ "error": error,
195
+ "cached": cached,
196
+ },
197
+ )
198
+ return CheckpointResponse(
199
+ id=UUID(body["id"]) if isinstance(body["id"], str) else body["id"],
200
+ execution_id=UUID(body["execution_id"])
201
+ if isinstance(body["execution_id"], str)
202
+ else body["execution_id"],
203
+ step_number=body["step_number"],
204
+ completed_at=body["completed_at"],
205
+ state_hash=body.get("state_hash"),
206
+ created_at=body.get("created_at"),
207
+ )
208
+
209
+ async def resume_execution(self, execution_id: UUID) -> ResumeState:
210
+ body = await self._arequest(
211
+ "POST",
212
+ f"/executions/{execution_id}/resume",
213
+ )
214
+ return ResumeState(
215
+ execution_id=UUID(body["execution_id"])
216
+ if isinstance(body["execution_id"], str)
217
+ else body["execution_id"],
218
+ checkpoint_step=body.get("checkpoint_step", 0),
219
+ reconstructed_state=body.get("reconstructed_state", {}),
220
+ state_hash=body.get("state_hash"),
221
+ ready_to_resume=body.get("ready_to_resume", True),
222
+ )
223
+
224
+ async def replay_from_step(
225
+ self,
226
+ execution_id: UUID,
227
+ step_number: int,
228
+ ) -> ReplayState:
229
+ body = await self._arequest(
230
+ "POST",
231
+ f"/executions/{execution_id}/replay",
232
+ json_body={"step_number": step_number},
233
+ )
234
+ return ReplayState(
235
+ execution_id=UUID(body["execution_id"])
236
+ if isinstance(body["execution_id"], str)
237
+ else body["execution_id"],
238
+ replay_from_step=body.get("replay_from_step", step_number),
239
+ reconstructed_state=body.get("reconstructed_state", {}),
240
+ ready_to_resume=body.get("ready_to_resume", True),
241
+ )
242
+
243
+ async def get_execution_history(self, execution_id: UUID) -> ExecutionHistory:
244
+ body = await self._arequest("GET", f"/executions/{execution_id}/history")
245
+ steps = [ExecutionStep(**s) for s in body.get("steps", [])]
246
+ return ExecutionHistory(
247
+ execution_id=UUID(body["execution_id"])
248
+ if isinstance(body["execution_id"], str)
249
+ else body["execution_id"],
250
+ agent_id=body["agent_id"],
251
+ status=body["status"],
252
+ started_at=body["started_at"],
253
+ completed_at=body.get("completed_at"),
254
+ steps=steps,
255
+ )
@@ -0,0 +1,115 @@
1
+ """
2
+ Agent workflow with mocked LLM calls, demonstrating crash recovery.
3
+
4
+ Prerequisites:
5
+ - Waypoint API running at http://localhost:9654
6
+
7
+ Usage:
8
+ uv run python -m sdk.examples.agent_with_llm_mock
9
+
10
+ This simulates:
11
+ 1. Create a new execution
12
+ 2. Normal execution with an LLM call
13
+ 3. Resume after a simulated crash: the LLM response is served from cache
14
+ """
15
+
16
+ import asyncio
17
+
18
+ from sdk import Waypoint, checkpoint
19
+
20
+ AGENT_ID = "llm_agent"
21
+ API_BASE_URL = "http://localhost:9654/api/v1/"
22
+
23
+ waypoint = Waypoint(
24
+ base_url=API_BASE_URL,
25
+ agent_id=AGENT_ID,
26
+ ).use()
27
+
28
+
29
+ @checkpoint("load_context", cache=True)
30
+ async def load_context(user_query: str):
31
+ return {
32
+ "query": user_query,
33
+ "context": {"user_id": "abc123", "session": "test"},
34
+ }
35
+
36
+
37
+ async def mock_llm_call(prompt: str) -> dict:
38
+ """Simulate an expensive LLM API call."""
39
+ await asyncio.sleep(0.05)
40
+ return {
41
+ "response": f"Analysis of: {prompt[:50]}...",
42
+ "tokens_used": 150,
43
+ "model": "gpt-4",
44
+ }
45
+
46
+
47
+ @checkpoint("call_llm", cache=True)
48
+ async def call_llm(context: dict):
49
+ print(" Calling LLM (this is expensive)...")
50
+ return await mock_llm_call(context["query"])
51
+
52
+
53
+ @checkpoint("format_output")
54
+ async def format_output(data: dict):
55
+ response = data["llm"]["response"]
56
+ return {"formatted": f"<result>{response}</result>", "meta": data["llm"]}
57
+
58
+
59
+ async def first_run():
60
+ """First execution: create, run steps, return execution_id for recovery demo."""
61
+ execution_id = await waypoint.create()
62
+ print(f"Created execution: {execution_id}")
63
+
64
+ context = await load_context(user_query="What is event sourcing?")
65
+ print("Step 1 (load_context): context loaded")
66
+
67
+ llm_result = await call_llm(context=context)
68
+ print("Step 2 (call_llm): LLM responded")
69
+
70
+ output = await format_output(data={"llm": llm_result, "context": context})
71
+ print(f"Step 3 (format_output): {output['formatted'][:100]}...")
72
+
73
+ print(f"\nExecution completed! Total steps: {waypoint.get_step_number()}")
74
+ return execution_id
75
+
76
+
77
+ async def crash_recovery_demo(execution_id):
78
+ """
79
+ Simulate a crash and recovery.
80
+ A new Waypoint instance resumes the execution; the cached LLM response
81
+ is returned without re-invoking the LLM call.
82
+ """
83
+ print("\n--- CRASH RECOVERY ---")
84
+ print(f"Resuming execution {execution_id}...")
85
+
86
+ waypoint2 = Waypoint(
87
+ base_url=API_BASE_URL,
88
+ agent_id=AGENT_ID,
89
+ ).use()
90
+
91
+ resume = await waypoint2.resume(execution_id)
92
+ print(f"Resumed from step {resume.checkpoint_step}")
93
+ print(f"Recovered state keys: {list(waypoint2.get_state().keys())}")
94
+
95
+ context = await load_context(user_query="What is event sourcing?")
96
+ print("Step 1 (load_context, cached): context loaded (from cache)")
97
+
98
+ llm_result = await call_llm(context=context)
99
+ print("Step 2 (call_llm, cached): LLM responded (from cache, no re-execution)")
100
+
101
+ output = await format_output(data={"llm": llm_result, "context": context})
102
+ print(f"Step 3 (format_output, fresh): {output['formatted'][:100]}...")
103
+
104
+ print(f"\nRecovery complete! Total steps: {waypoint2.get_step_number()}")
105
+ await waypoint2.aclose()
106
+
107
+
108
+ async def main():
109
+ execution_id = await first_run()
110
+ await crash_recovery_demo(execution_id)
111
+ await waypoint.aclose()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ asyncio.run(main())
@@ -0,0 +1,69 @@
1
+ """
2
+ Simple 3-step agent workflow using the Waypoint SDK.
3
+
4
+ Prerequisites:
5
+ - Waypoint API running at http://localhost:9654
6
+
7
+ Usage:
8
+ uv run python -m sdk.examples.simple_agent
9
+ """
10
+
11
+ import asyncio
12
+
13
+ from sdk import Waypoint, checkpoint
14
+
15
+ AGENT_ID = "simple_agent"
16
+ API_BASE_URL = "http://localhost:9654/api/v1/"
17
+
18
+ waypoint = Waypoint(
19
+ base_url=API_BASE_URL,
20
+ agent_id=AGENT_ID,
21
+ ).use()
22
+
23
+
24
+ @checkpoint("load_query")
25
+ async def load_query(query: str):
26
+ return {"query": query, "normalized": query.lower()}
27
+
28
+
29
+ @checkpoint("search")
30
+ async def search(data: dict):
31
+ results = [
32
+ "Waypoint provides agent execution recovery",
33
+ "Event sourcing enables deterministic replay",
34
+ ]
35
+ return {**data, "results": results}
36
+
37
+
38
+ @checkpoint("summarize", cache=True)
39
+ async def summarize(data: dict):
40
+ summary = "Waypoint is a fault-tolerant execution recovery system."
41
+ return {**data, "summary": summary}
42
+
43
+
44
+ async def main():
45
+ execution_id = await waypoint.create()
46
+ print(f"Created execution: {execution_id}")
47
+
48
+ try:
49
+ step1 = await load_query(query="What is Waypoint?")
50
+ print(f"Step 1 (load_query): {step1}")
51
+
52
+ step2 = await search(data=step1)
53
+ print(f"Step 2 (search): {step2}")
54
+
55
+ step3 = await summarize(data=step2)
56
+ print(f"Step 3 (summarize): {step3}")
57
+
58
+ print(f"\nExecution completed! Total steps: {waypoint.get_step_number()}")
59
+ print(f"Final state: {waypoint.get_state()}")
60
+
61
+ except Exception as e:
62
+ print(f"Execution failed: {e}")
63
+ raise
64
+
65
+ await waypoint.aclose()
66
+
67
+
68
+ if __name__ == "__main__":
69
+ asyncio.run(main())
sdk/exceptions.py ADDED
@@ -0,0 +1,52 @@
1
+ class WaypointError(Exception):
2
+ """Base exception for all Waypoint SDK errors."""
3
+
4
+ pass
5
+
6
+
7
+ class GatewayError(WaypointError):
8
+ """Raised when the Waypoint API is unreachable and no fail-open policy applies."""
9
+
10
+ def __init__(self, gateway_url: str, original: Exception) -> None:
11
+ self.gateway_url = gateway_url
12
+ self.original = original
13
+ super().__init__(f"Waypoint API at {gateway_url!r} unreachable: {original}")
14
+
15
+
16
+ class CheckpointError(WaypointError):
17
+ """Raised when a checkpoint operation fails."""
18
+
19
+ pass
20
+
21
+
22
+ class ExecutionNotFoundError(WaypointError):
23
+ """Raised when the execution does not exist on the server."""
24
+
25
+ pass
26
+
27
+
28
+ class CheckpointNotFoundError(WaypointError):
29
+ """Raised when no checkpoint exists for an execution."""
30
+
31
+ pass
32
+
33
+
34
+ class WaypointClientError(WaypointError):
35
+ """Raised when the API returns an unexpected error status."""
36
+
37
+ def __init__(self, status_code: int, detail: str) -> None:
38
+ self.status_code = status_code
39
+ self.detail = detail
40
+ super().__init__(f"API error {status_code}: {detail}")
41
+
42
+
43
+ class WaypointConnectionError(WaypointError):
44
+ """Raised when the SDK cannot connect to the Waypoint API."""
45
+
46
+ pass
47
+
48
+
49
+ class WaypointTimeoutError(WaypointError):
50
+ """Raised when a request to the Waypoint API times out."""
51
+
52
+ pass