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