oagi-core 0.9.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.
Files changed (60) hide show
  1. oagi/__init__.py +108 -0
  2. oagi/agent/__init__.py +31 -0
  3. oagi/agent/default.py +75 -0
  4. oagi/agent/factories.py +50 -0
  5. oagi/agent/protocol.py +55 -0
  6. oagi/agent/registry.py +155 -0
  7. oagi/agent/tasker/__init__.py +35 -0
  8. oagi/agent/tasker/memory.py +184 -0
  9. oagi/agent/tasker/models.py +83 -0
  10. oagi/agent/tasker/planner.py +385 -0
  11. oagi/agent/tasker/taskee_agent.py +395 -0
  12. oagi/agent/tasker/tasker_agent.py +323 -0
  13. oagi/async_pyautogui_action_handler.py +44 -0
  14. oagi/async_screenshot_maker.py +47 -0
  15. oagi/async_single_step.py +85 -0
  16. oagi/cli/__init__.py +11 -0
  17. oagi/cli/agent.py +125 -0
  18. oagi/cli/main.py +77 -0
  19. oagi/cli/server.py +94 -0
  20. oagi/cli/utils.py +82 -0
  21. oagi/client/__init__.py +12 -0
  22. oagi/client/async_.py +293 -0
  23. oagi/client/base.py +465 -0
  24. oagi/client/sync.py +296 -0
  25. oagi/exceptions.py +118 -0
  26. oagi/logging.py +47 -0
  27. oagi/pil_image.py +102 -0
  28. oagi/pyautogui_action_handler.py +268 -0
  29. oagi/screenshot_maker.py +41 -0
  30. oagi/server/__init__.py +13 -0
  31. oagi/server/agent_wrappers.py +98 -0
  32. oagi/server/config.py +46 -0
  33. oagi/server/main.py +157 -0
  34. oagi/server/models.py +98 -0
  35. oagi/server/session_store.py +116 -0
  36. oagi/server/socketio_server.py +405 -0
  37. oagi/single_step.py +87 -0
  38. oagi/task/__init__.py +14 -0
  39. oagi/task/async_.py +97 -0
  40. oagi/task/async_short.py +64 -0
  41. oagi/task/base.py +121 -0
  42. oagi/task/short.py +64 -0
  43. oagi/task/sync.py +97 -0
  44. oagi/types/__init__.py +28 -0
  45. oagi/types/action_handler.py +30 -0
  46. oagi/types/async_action_handler.py +30 -0
  47. oagi/types/async_image_provider.py +37 -0
  48. oagi/types/image.py +17 -0
  49. oagi/types/image_provider.py +34 -0
  50. oagi/types/models/__init__.py +32 -0
  51. oagi/types/models/action.py +33 -0
  52. oagi/types/models/client.py +64 -0
  53. oagi/types/models/image_config.py +47 -0
  54. oagi/types/models/step.py +17 -0
  55. oagi/types/url_image.py +47 -0
  56. oagi_core-0.9.0.dist-info/METADATA +257 -0
  57. oagi_core-0.9.0.dist-info/RECORD +60 -0
  58. oagi_core-0.9.0.dist-info/WHEEL +4 -0
  59. oagi_core-0.9.0.dist-info/entry_points.txt +2 -0
  60. oagi_core-0.9.0.dist-info/licenses/LICENSE +21 -0
oagi/client/base.py ADDED
@@ -0,0 +1,465 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Copyright (c) OpenAGI Foundation
3
+ # All rights reserved.
4
+ #
5
+ # This file is part of the official API project.
6
+ # Licensed under the MIT License.
7
+ # -----------------------------------------------------------------------------
8
+
9
+ import os
10
+ from typing import Any, Generic, TypeVar
11
+
12
+ import httpx
13
+
14
+ from ..exceptions import (
15
+ APIError,
16
+ AuthenticationError,
17
+ ConfigurationError,
18
+ NetworkError,
19
+ NotFoundError,
20
+ RateLimitError,
21
+ RequestTimeoutError,
22
+ ServerError,
23
+ ValidationError,
24
+ )
25
+ from ..logging import get_logger
26
+ from ..types.models import (
27
+ ErrorResponse,
28
+ GenerateResponse,
29
+ LLMResponse,
30
+ UploadFileResponse,
31
+ )
32
+
33
+ logger = get_logger("client.base")
34
+
35
+ # TypeVar for HTTP client type (httpx.Client or httpx.AsyncClient)
36
+ HttpClientT = TypeVar("HttpClientT")
37
+
38
+
39
+ class BaseClient(Generic[HttpClientT]):
40
+ """Base class with shared business logic for sync/async clients."""
41
+
42
+ def __init__(self, base_url: str | None = None, api_key: str | None = None):
43
+ # Get from environment if not provided
44
+ self.base_url = base_url or os.getenv("OAGI_BASE_URL")
45
+ self.api_key = api_key or os.getenv("OAGI_API_KEY")
46
+
47
+ # Validate required configuration
48
+ if not self.base_url:
49
+ raise ConfigurationError(
50
+ "OAGI base URL must be provided either as 'base_url' parameter or "
51
+ "OAGI_BASE_URL environment variable"
52
+ )
53
+
54
+ if not self.api_key:
55
+ raise ConfigurationError(
56
+ "OAGI API key must be provided either as 'api_key' parameter or "
57
+ "OAGI_API_KEY environment variable"
58
+ )
59
+
60
+ self.base_url = self.base_url.rstrip("/")
61
+ self.timeout = 60
62
+ self.client: HttpClientT # Will be set by subclasses
63
+
64
+ logger.info(f"Client initialized with base_url: {self.base_url}")
65
+
66
+ def _build_headers(self, api_version: str | None = None) -> dict[str, str]:
67
+ headers: dict[str, str] = {}
68
+ if api_version:
69
+ headers["x-api-version"] = api_version
70
+ if self.api_key:
71
+ headers["x-api-key"] = self.api_key
72
+ return headers
73
+
74
+ def _build_payload(
75
+ self,
76
+ model: str,
77
+ messages_history: list,
78
+ task_description: str | None = None,
79
+ task_id: str | None = None,
80
+ temperature: float | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Build OpenAI-compatible request payload.
83
+
84
+ Args:
85
+ model: Model to use
86
+ messages_history: OpenAI-compatible message history
87
+ task_description: Task description
88
+ task_id: Task ID for continuing session
89
+ temperature: Sampling temperature
90
+
91
+ Returns:
92
+ OpenAI-compatible request payload
93
+ """
94
+ payload: dict[str, Any] = {
95
+ "model": model,
96
+ "messages": messages_history,
97
+ }
98
+
99
+ if task_description is not None:
100
+ payload["task_description"] = task_description
101
+ if task_id is not None:
102
+ payload["task_id"] = task_id
103
+ if temperature is not None:
104
+ payload["temperature"] = temperature
105
+
106
+ return payload
107
+
108
+ def _handle_response_error(
109
+ self, response: httpx.Response, response_data: dict
110
+ ) -> None:
111
+ error_resp = ErrorResponse(**response_data)
112
+ if error_resp.error:
113
+ error_code = error_resp.error.code
114
+ error_msg = error_resp.error.message
115
+ logger.error(f"API Error [{error_code}]: {error_msg}")
116
+
117
+ # Map to specific exception types based on status code
118
+ exception_class = self._get_exception_class(response.status_code)
119
+ raise exception_class(
120
+ error_msg,
121
+ code=error_code,
122
+ status_code=response.status_code,
123
+ response=response,
124
+ )
125
+ else:
126
+ # Error response without error details
127
+ logger.error(f"API error response without details: {response.status_code}")
128
+ exception_class = self._get_exception_class(response.status_code)
129
+ raise exception_class(
130
+ f"API error (status {response.status_code})",
131
+ status_code=response.status_code,
132
+ response=response,
133
+ )
134
+
135
+ def _get_exception_class(self, status_code: int) -> type[APIError]:
136
+ status_map = {
137
+ 401: AuthenticationError,
138
+ 404: NotFoundError,
139
+ 422: ValidationError,
140
+ 429: RateLimitError,
141
+ }
142
+
143
+ if status_code >= 500:
144
+ return ServerError
145
+
146
+ return status_map.get(status_code, APIError)
147
+
148
+ def _log_request_info(self, model: str, task_description: Any, task_id: Any):
149
+ logger.info(f"Making API request to /v2/message with model: {model}")
150
+ logger.debug(
151
+ f"Request includes task_description: {task_description is not None}, "
152
+ f"task_id: {task_id is not None}"
153
+ )
154
+
155
+ def _build_user_message(
156
+ self, screenshot_url: str, instruction: str | None
157
+ ) -> dict[str, Any]:
158
+ """Build OpenAI-compatible user message with screenshot and optional instruction.
159
+
160
+ Args:
161
+ screenshot_url: URL of uploaded screenshot
162
+ instruction: Optional text instruction
163
+
164
+ Returns:
165
+ User message dict
166
+ """
167
+ content = [{"type": "image_url", "image_url": {"url": screenshot_url}}]
168
+ if instruction:
169
+ content.append({"type": "text", "text": instruction})
170
+ return {"role": "user", "content": content}
171
+
172
+ def _prepare_message_payload(
173
+ self,
174
+ model: str,
175
+ upload_file_response: UploadFileResponse | None,
176
+ task_description: str | None,
177
+ task_id: str | None,
178
+ instruction: str | None,
179
+ messages_history: list | None,
180
+ temperature: float | None,
181
+ api_version: str | None,
182
+ screenshot_url: str | None = None,
183
+ ) -> tuple[dict[str, str], dict[str, Any]]:
184
+ """Prepare headers and payload for /v2/message request.
185
+
186
+ Args:
187
+ model: Model to use
188
+ upload_file_response: Response from S3 upload (if screenshot was uploaded)
189
+ task_description: Task description
190
+ task_id: Task ID
191
+ instruction: Optional instruction
192
+ messages_history: Message history
193
+ temperature: Sampling temperature
194
+ api_version: API version
195
+ screenshot_url: Direct screenshot URL (alternative to upload_file_response)
196
+
197
+ Returns:
198
+ Tuple of (headers, payload)
199
+ """
200
+ # Use provided screenshot_url or get from upload_file_response
201
+ if screenshot_url is None:
202
+ if upload_file_response is None:
203
+ raise ValueError(
204
+ "Either screenshot_url or upload_file_response must be provided"
205
+ )
206
+ screenshot_url = upload_file_response.download_url
207
+
208
+ # Build user message and append to history
209
+ if messages_history is None:
210
+ messages_history = []
211
+ user_message = self._build_user_message(screenshot_url, instruction)
212
+ messages_history.append(user_message)
213
+
214
+ # Build payload and headers
215
+ headers = self._build_headers(api_version)
216
+ payload = self._build_payload(
217
+ model=model,
218
+ messages_history=messages_history,
219
+ task_description=task_description,
220
+ task_id=task_id,
221
+ temperature=temperature,
222
+ )
223
+
224
+ return headers, payload
225
+
226
+ def _parse_response_json(self, response: httpx.Response) -> dict[str, Any]:
227
+ try:
228
+ return response.json()
229
+ except ValueError:
230
+ logger.error(f"Non-JSON API response: {response.status_code}")
231
+ raise APIError(
232
+ f"Invalid response format (status {response.status_code})",
233
+ status_code=response.status_code,
234
+ response=response,
235
+ )
236
+
237
+ def _process_response(self, response: httpx.Response) -> "LLMResponse":
238
+ response_data = self._parse_response_json(response)
239
+
240
+ # Check if it's an error response (non-200 status)
241
+ if response.status_code != 200:
242
+ self._handle_response_error(response, response_data)
243
+
244
+ # Parse successful response
245
+ result = LLMResponse(**response_data)
246
+
247
+ # Check if the response contains an error (even with 200 status)
248
+ if result.error:
249
+ logger.error(
250
+ f"API Error in response: [{result.error.code}]: {result.error.message}"
251
+ )
252
+ raise APIError(
253
+ result.error.message,
254
+ code=result.error.code,
255
+ status_code=200,
256
+ response=response,
257
+ )
258
+
259
+ logger.info(
260
+ f"API request successful - task_id: {result.task_id}, "
261
+ f"complete: {result.is_complete}"
262
+ )
263
+ logger.debug(f"Response included {len(result.actions)} actions")
264
+ return result
265
+
266
+ def _process_upload_response(self, response: httpx.Response) -> UploadFileResponse:
267
+ """Process response from /v1/file/upload endpoint.
268
+
269
+ Args:
270
+ response: HTTP response from upload endpoint
271
+
272
+ Returns:
273
+ UploadFileResponse with presigned URL
274
+
275
+ Raises:
276
+ RequestTimeoutError: If request times out
277
+ NetworkError: If network error occurs
278
+ APIError: If API returns error or invalid response
279
+ """
280
+ try:
281
+ response_data = response.json()
282
+ upload_file_response = UploadFileResponse(**response_data)
283
+ logger.debug("Calling /v1/file/upload successful")
284
+ return upload_file_response
285
+ except ValueError:
286
+ logger.error(f"Non-JSON API response: {response.status_code}")
287
+ raise APIError(
288
+ f"Invalid response format (status {response.status_code})",
289
+ status_code=response.status_code,
290
+ response=response,
291
+ )
292
+ except KeyError as e:
293
+ logger.error(f"Invalid response: {response.status_code}")
294
+ raise APIError(
295
+ f"Invalid presigned S3 URL response: missing field {e}",
296
+ status_code=response.status_code,
297
+ response=response,
298
+ )
299
+
300
+ def _handle_upload_http_errors(
301
+ self, e: Exception, response: httpx.Response | None = None
302
+ ):
303
+ """Handle HTTP errors during upload request.
304
+
305
+ Args:
306
+ e: The exception that occurred
307
+ response: Optional HTTP response
308
+
309
+ Raises:
310
+ RequestTimeoutError: If request times out
311
+ NetworkError: If network error occurs
312
+ APIError: For other HTTP errors
313
+ """
314
+ if isinstance(e, httpx.TimeoutException):
315
+ logger.error(f"Request timed out after {self.timeout} seconds")
316
+ raise RequestTimeoutError(
317
+ f"Request timed out after {self.timeout} seconds", e
318
+ )
319
+ elif isinstance(e, httpx.NetworkError):
320
+ logger.error(f"Network error: {e}")
321
+ raise NetworkError(f"Network error: {e}", e)
322
+ elif isinstance(e, httpx.HTTPStatusError) and response:
323
+ logger.warning(f"Invalid status code: {e}")
324
+ exception_class = self._get_exception_class(response.status_code)
325
+ raise exception_class(
326
+ f"API error (status {response.status_code})",
327
+ status_code=response.status_code,
328
+ response=response,
329
+ )
330
+ else:
331
+ raise
332
+
333
+ def _handle_s3_upload_error(
334
+ self, e: Exception, response: httpx.Response | None = None
335
+ ):
336
+ """Handle S3 upload errors.
337
+
338
+ Args:
339
+ e: The exception that occurred
340
+ response: Optional HTTP response from S3
341
+
342
+ Raises:
343
+ APIError: Wrapping the S3 upload error
344
+ """
345
+ logger.error(f"S3 upload failed: {e}")
346
+ status_code = response.status_code if response else 500
347
+ raise APIError(message=str(e), status_code=status_code, response=response)
348
+
349
+ def _prepare_worker_request(
350
+ self,
351
+ worker_id: str,
352
+ overall_todo: str,
353
+ task_description: str,
354
+ todos: list[dict],
355
+ deliverables: list[dict],
356
+ history: list[dict] | None = None,
357
+ current_todo_index: int | None = None,
358
+ task_execution_summary: str | None = None,
359
+ current_screenshot: str | None = None,
360
+ current_subtask_instruction: str | None = None,
361
+ window_steps: list[dict] | None = None,
362
+ window_screenshots: list[str] | None = None,
363
+ result_screenshot: str | None = None,
364
+ prior_notes: str | None = None,
365
+ latest_todo_summary: str | None = None,
366
+ api_version: str | None = None,
367
+ ) -> tuple[dict[str, Any], dict[str, str]]:
368
+ """Prepare worker request with validation, payload, and headers.
369
+
370
+ Args:
371
+ worker_id: One of "oagi_first", "oagi_follow", "oagi_task_summary"
372
+ overall_todo: Current todo description
373
+ task_description: Overall task description
374
+ todos: List of todo dicts with index, description, status, execution_summary
375
+ deliverables: List of deliverable dicts with description, achieved
376
+ history: List of history dicts with todo_index, todo_description, action_count, summary, completed
377
+ current_todo_index: Index of current todo being executed
378
+ task_execution_summary: Summary of overall task execution
379
+ current_screenshot: Uploaded file UUID for screenshot (oagi_first)
380
+ current_subtask_instruction: Subtask instruction (oagi_follow)
381
+ window_steps: Action steps list (oagi_follow)
382
+ window_screenshots: Uploaded file UUIDs list (oagi_follow)
383
+ result_screenshot: Uploaded file UUID for result screenshot (oagi_follow)
384
+ prior_notes: Execution notes (oagi_follow)
385
+ latest_todo_summary: Latest summary (oagi_task_summary)
386
+ api_version: API version header
387
+
388
+ Returns:
389
+ Tuple of (payload dict, headers dict)
390
+
391
+ Raises:
392
+ ValueError: If worker_id is invalid
393
+ """
394
+ # Validate worker_id
395
+ valid_workers = {"oagi_first", "oagi_follow", "oagi_task_summary"}
396
+ if worker_id not in valid_workers:
397
+ raise ValueError(
398
+ f"Invalid worker_id '{worker_id}'. Must be one of: {valid_workers}"
399
+ )
400
+
401
+ logger.info(f"Calling /v1/generate with worker_id: {worker_id}")
402
+
403
+ # Build flattened payload (no oagi_data wrapper)
404
+ payload: dict[str, Any] = {
405
+ "external_worker_id": worker_id,
406
+ "overall_todo": overall_todo,
407
+ "task_description": task_description,
408
+ "todos": todos,
409
+ "deliverables": deliverables,
410
+ "history": history or [],
411
+ }
412
+
413
+ # Add optional memory fields
414
+ if current_todo_index is not None:
415
+ payload["current_todo_index"] = current_todo_index
416
+ if task_execution_summary is not None:
417
+ payload["task_execution_summary"] = task_execution_summary
418
+
419
+ # Add optional screenshot/worker-specific fields
420
+ if current_screenshot is not None:
421
+ payload["current_screenshot"] = current_screenshot
422
+ if current_subtask_instruction is not None:
423
+ payload["current_subtask_instruction"] = current_subtask_instruction
424
+ if window_steps is not None:
425
+ payload["window_steps"] = window_steps
426
+ if window_screenshots is not None:
427
+ payload["window_screenshots"] = window_screenshots
428
+ if result_screenshot is not None:
429
+ payload["result_screenshot"] = result_screenshot
430
+ if prior_notes is not None:
431
+ payload["prior_notes"] = prior_notes
432
+ if latest_todo_summary is not None:
433
+ payload["latest_todo_summary"] = latest_todo_summary
434
+
435
+ # Build headers
436
+ headers = self._build_headers(api_version)
437
+
438
+ return payload, headers
439
+
440
+ def _process_generate_response(self, response: httpx.Response) -> GenerateResponse:
441
+ """Process response from /v1/generate endpoint.
442
+
443
+ Args:
444
+ response: HTTP response from generate endpoint
445
+
446
+ Returns:
447
+ GenerateResponse with LLM output
448
+
449
+ Raises:
450
+ APIError: If API returns error or invalid response
451
+ """
452
+ response_data = self._parse_response_json(response)
453
+
454
+ # Check if it's an error response (non-200 status)
455
+ if response.status_code != 200:
456
+ self._handle_response_error(response, response_data)
457
+
458
+ # Parse successful response
459
+ result = GenerateResponse(**response_data)
460
+
461
+ logger.info(
462
+ f"Generate request successful - tokens: {result.prompt_tokens}+{result.completion_tokens}, "
463
+ f"cost: ${result.cost:.6f}"
464
+ )
465
+ return result