oagi 0.4.2__tar.gz → 0.5.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.

Potentially problematic release.


This version of oagi might be problematic. Click here for more details.

Files changed (58) hide show
  1. {oagi-0.4.2 → oagi-0.5.0}/.github/workflows/release.yml +10 -3
  2. {oagi-0.4.2 → oagi-0.5.0}/PKG-INFO +1 -1
  3. oagi-0.5.0/examples/continued_session.py +53 -0
  4. {oagi-0.4.2 → oagi-0.5.0}/pyproject.toml +1 -1
  5. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_client.py +8 -0
  6. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_short_task.py +18 -2
  7. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_task.py +25 -2
  8. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/pyautogui_action_handler.py +18 -1
  9. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/short_task.py +18 -2
  10. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/sync_client.py +8 -0
  11. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/task.py +25 -2
  12. {oagi-0.4.2 → oagi-0.5.0}/tests/test_pyautogui_action_handler.py +70 -0
  13. {oagi-0.4.2 → oagi-0.5.0}/tests/test_sync_client.py +138 -0
  14. {oagi-0.4.2 → oagi-0.5.0}/tests/test_task.py +150 -0
  15. {oagi-0.4.2 → oagi-0.5.0}/uv.lock +1 -1
  16. {oagi-0.4.2 → oagi-0.5.0}/.github/workflows/ci.yml +0 -0
  17. {oagi-0.4.2 → oagi-0.5.0}/.gitignore +0 -0
  18. {oagi-0.4.2 → oagi-0.5.0}/.python-version +0 -0
  19. {oagi-0.4.2 → oagi-0.5.0}/CONTRIBUTING.md +0 -0
  20. {oagi-0.4.2 → oagi-0.5.0}/LICENSE +0 -0
  21. {oagi-0.4.2 → oagi-0.5.0}/Makefile +0 -0
  22. {oagi-0.4.2 → oagi-0.5.0}/README.md +0 -0
  23. {oagi-0.4.2 → oagi-0.5.0}/examples/async_google_weather.py +0 -0
  24. {oagi-0.4.2 → oagi-0.5.0}/examples/execute_task_auto.py +0 -0
  25. {oagi-0.4.2 → oagi-0.5.0}/examples/execute_task_manual.py +0 -0
  26. {oagi-0.4.2 → oagi-0.5.0}/examples/google_weather.py +0 -0
  27. {oagi-0.4.2 → oagi-0.5.0}/examples/hotel_booking.py +0 -0
  28. {oagi-0.4.2 → oagi-0.5.0}/examples/screenshot_with_config.py +0 -0
  29. {oagi-0.4.2 → oagi-0.5.0}/examples/single_step.py +0 -0
  30. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/__init__.py +0 -0
  31. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_pyautogui_action_handler.py +0 -0
  32. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_screenshot_maker.py +0 -0
  33. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/async_single_step.py +0 -0
  34. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/exceptions.py +0 -0
  35. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/logging.py +0 -0
  36. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/pil_image.py +0 -0
  37. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/screenshot_maker.py +0 -0
  38. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/single_step.py +0 -0
  39. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/__init__.py +0 -0
  40. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/action_handler.py +0 -0
  41. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/async_action_handler.py +0 -0
  42. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/async_image_provider.py +0 -0
  43. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/image.py +0 -0
  44. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/image_provider.py +0 -0
  45. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/models/__init__.py +0 -0
  46. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/models/action.py +0 -0
  47. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/models/image_config.py +0 -0
  48. {oagi-0.4.2 → oagi-0.5.0}/src/oagi/types/models/step.py +0 -0
  49. {oagi-0.4.2 → oagi-0.5.0}/tests/__init__.py +0 -0
  50. {oagi-0.4.2 → oagi-0.5.0}/tests/conftest.py +0 -0
  51. {oagi-0.4.2 → oagi-0.5.0}/tests/test_async_client.py +0 -0
  52. {oagi-0.4.2 → oagi-0.5.0}/tests/test_async_handlers.py +0 -0
  53. {oagi-0.4.2 → oagi-0.5.0}/tests/test_async_task.py +0 -0
  54. {oagi-0.4.2 → oagi-0.5.0}/tests/test_logging.py +0 -0
  55. {oagi-0.4.2 → oagi-0.5.0}/tests/test_pil_image.py +0 -0
  56. {oagi-0.4.2 → oagi-0.5.0}/tests/test_screenshot_maker.py +0 -0
  57. {oagi-0.4.2 → oagi-0.5.0}/tests/test_short_task.py +0 -0
  58. {oagi-0.4.2 → oagi-0.5.0}/tests/test_single_step.py +0 -0
@@ -32,10 +32,17 @@ jobs:
32
32
  password: ${{ secrets.PYPI_API_TOKEN }}
33
33
  # Another option: Use Trusted Publishing (recommended, no token needed)
34
34
  # Configure at: https://pypi.org/manage/project/oagi/settings/publishing/
35
-
35
+
36
+ - name: Get commit message
37
+ id: commit_message
38
+ run: |
39
+ # Get the commit message for the tagged commit
40
+ echo "message<<EOF" >> $GITHUB_OUTPUT
41
+ git log -1 --pretty=%B >> $GITHUB_OUTPUT
42
+ echo "EOF" >> $GITHUB_OUTPUT
36
43
 
37
44
  - name: Create GitHub Release
38
45
  uses: softprops/action-gh-release@v1
39
46
  with:
40
- files: dist/*
41
- generate_release_notes: true
47
+ body: ${{ steps.commit_message.outputs.message }}
48
+ files: dist/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: oagi
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Official API of OpenAGI Foundation
5
5
  Project-URL: Homepage, https://github.com/agiopen-org/oagi
6
6
  Author-email: OpenAGI Foundation <contact@agiopen.org>
@@ -0,0 +1,53 @@
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
+ from oagi import ScreenshotMaker, ShortTask
10
+
11
+
12
+ def main():
13
+ # First session - start a new task
14
+ print("=== First Session ===")
15
+ task1 = ShortTask()
16
+ task1.init_task("Open calculator app")
17
+
18
+ image_provider = ScreenshotMaker()
19
+
20
+ # Execute a few steps in the first session
21
+ for i in range(1):
22
+ image = image_provider()
23
+ step = task1.step(image)
24
+ print(f"Session 1, Step {i + 1}: {step.reason}, Actions: {step.actions}")
25
+
26
+ if step.stop:
27
+ break
28
+
29
+ # Save the task_id from the first session
30
+ previous_task_id = task1.task_id
31
+ print(f"\nFirst session task_id: {previous_task_id}")
32
+
33
+ # Second session - continue with context from the first session
34
+ print("\n=== Second Session (with history) ===")
35
+ task2 = ShortTask()
36
+ task2.init_task("Calculate 25 * 4", last_task_id=previous_task_id, history_steps=1)
37
+
38
+ # Execute steps with history context
39
+ for i in range(3):
40
+ image = image_provider()
41
+ step = task2.step(image)
42
+ print(f"Session 2, Step {i + 1}: {step.reason}, Actions: {step.actions}")
43
+
44
+ if step.stop:
45
+ print("✓ Task completed!")
46
+ break
47
+
48
+ print(f"\nSecond session task_id: {task2.task_id}")
49
+ print(f"Used history from: {task2.last_task_id}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "oagi"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Official API of OpenAGI Foundation"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -91,6 +91,8 @@ class AsyncClient:
91
91
  task_id: str | None = None,
92
92
  instruction: str | None = None,
93
93
  max_actions: int | None = 5,
94
+ last_task_id: str | None = None,
95
+ history_steps: int | None = None,
94
96
  api_version: str | None = None,
95
97
  ) -> LLMResponse:
96
98
  """
@@ -103,6 +105,8 @@ class AsyncClient:
103
105
  task_id: Task ID for continuing existing task
104
106
  instruction: Additional instruction when continuing a session (only works with task_id)
105
107
  max_actions: Maximum number of actions to return (1-20)
108
+ last_task_id: Previous task ID to retrieve history from (only works with task_id)
109
+ history_steps: Number of historical steps to include from last_task_id (default: 1, max: 10)
106
110
  api_version: API version header
107
111
 
108
112
  Returns:
@@ -127,6 +131,10 @@ class AsyncClient:
127
131
  payload["instruction"] = instruction
128
132
  if max_actions is not None:
129
133
  payload["max_actions"] = max_actions
134
+ if last_task_id is not None:
135
+ payload["last_task_id"] = last_task_id
136
+ if history_steps is not None:
137
+ payload["history_steps"] = history_steps
130
138
 
131
139
  logger.info(f"Making async API request to /v1/message with model: {model}")
132
140
  logger.debug(
@@ -30,12 +30,28 @@ class AsyncShortTask(AsyncTask):
30
30
  max_steps: int = 5,
31
31
  executor: AsyncActionHandler = None,
32
32
  image_provider: AsyncImageProvider = None,
33
+ last_task_id: str | None = None,
34
+ history_steps: int | None = None,
33
35
  ) -> bool:
34
- """Run the task in automatic mode with the provided executor and image provider."""
36
+ """Run the task in automatic mode with the provided executor and image provider.
37
+
38
+ Args:
39
+ task_desc: Task description
40
+ max_steps: Maximum number of steps
41
+ executor: Async handler to execute actions
42
+ image_provider: Async provider for screenshots
43
+ last_task_id: Previous task ID to retrieve history from
44
+ history_steps: Number of historical steps to include
45
+ """
35
46
  logger.info(
36
47
  f"Starting async auto mode for task: '{task_desc}' (max_steps: {max_steps})"
37
48
  )
38
- await self.init_task(task_desc, max_steps=max_steps)
49
+ await self.init_task(
50
+ task_desc,
51
+ max_steps=max_steps,
52
+ last_task_id=last_task_id,
53
+ history_steps=history_steps,
54
+ )
39
55
 
40
56
  for i in range(max_steps):
41
57
  logger.debug(f"Async auto mode step {i + 1}/{max_steps}")
@@ -29,10 +29,27 @@ class AsyncTask:
29
29
  self.task_id: str | None = None
30
30
  self.task_description: str | None = None
31
31
  self.model = model
32
+ self.last_task_id: str | None = None
33
+ self.history_steps: int | None = None
32
34
 
33
- async def init_task(self, task_desc: str, max_steps: int = 5):
34
- """Initialize a new task with the given description."""
35
+ async def init_task(
36
+ self,
37
+ task_desc: str,
38
+ max_steps: int = 5,
39
+ last_task_id: str | None = None,
40
+ history_steps: int | None = None,
41
+ ):
42
+ """Initialize a new task with the given description.
43
+
44
+ Args:
45
+ task_desc: Task description
46
+ max_steps: Maximum number of steps (for logging)
47
+ last_task_id: Previous task ID to retrieve history from
48
+ history_steps: Number of historical steps to include (default: 1)
49
+ """
35
50
  self.task_description = task_desc
51
+ self.last_task_id = last_task_id
52
+ self.history_steps = history_steps
36
53
  response = await self.client.create_message(
37
54
  model=self.model,
38
55
  screenshot="",
@@ -41,6 +58,10 @@ class AsyncTask:
41
58
  )
42
59
  self.task_id = response.task_id # Reset task_id for new task
43
60
  logger.info(f"Async task initialized: '{task_desc}' (max_steps: {max_steps})")
61
+ if last_task_id:
62
+ logger.info(
63
+ f"Will include {history_steps or 1} steps from previous task: {last_task_id}"
64
+ )
44
65
 
45
66
  async def step(
46
67
  self, screenshot: Image | bytes, instruction: str | None = None
@@ -74,6 +95,8 @@ class AsyncTask:
74
95
  task_description=self.task_description,
75
96
  task_id=self.task_id,
76
97
  instruction=instruction,
98
+ last_task_id=self.last_task_id if self.task_id else None,
99
+ history_steps=self.history_steps if self.task_id else None,
77
100
  )
78
101
 
79
102
  # Update task_id from response
@@ -101,9 +101,26 @@ class PyautoguiActionHandler:
101
101
  self.caps_manager = CapsLockManager(mode=self.config.capslock_mode)
102
102
 
103
103
  def _denormalize_coords(self, x: float, y: float) -> tuple[int, int]:
104
- """Convert coordinates from 0-1000 range to actual screen coordinates."""
104
+ """Convert coordinates from 0-1000 range to actual screen coordinates.
105
+
106
+ Also handles corner coordinates to prevent PyAutoGUI fail-safe trigger.
107
+ Corner coordinates (0,0), (0,max), (max,0), (max,max) are offset by 1 pixel.
108
+ """
105
109
  screen_x = int(x * self.screen_width / 1000)
106
110
  screen_y = int(y * self.screen_height / 1000)
111
+
112
+ # Prevent fail-safe by adjusting corner coordinates
113
+ # Check if coordinates are at screen corners (with small tolerance)
114
+ if screen_x < 1:
115
+ screen_x = 1
116
+ elif screen_x > self.screen_width - 1:
117
+ screen_x = self.screen_width - 1
118
+
119
+ if screen_y < 1:
120
+ screen_y = 1
121
+ elif screen_y > self.screen_height - 1:
122
+ screen_y = self.screen_height - 1
123
+
107
124
  return screen_x, screen_y
108
125
 
109
126
  def _parse_coords(self, args_str: str) -> tuple[int, int]:
@@ -30,12 +30,28 @@ class ShortTask(Task):
30
30
  max_steps: int = 5,
31
31
  executor: ActionHandler = None,
32
32
  image_provider: ImageProvider = None,
33
+ last_task_id: str | None = None,
34
+ history_steps: int | None = None,
33
35
  ) -> bool:
34
- """Run the task in automatic mode with the provided executor and image provider."""
36
+ """Run the task in automatic mode with the provided executor and image provider.
37
+
38
+ Args:
39
+ task_desc: Task description
40
+ max_steps: Maximum number of steps
41
+ executor: Handler to execute actions
42
+ image_provider: Provider for screenshots
43
+ last_task_id: Previous task ID to retrieve history from
44
+ history_steps: Number of historical steps to include
45
+ """
35
46
  logger.info(
36
47
  f"Starting auto mode for task: '{task_desc}' (max_steps: {max_steps})"
37
48
  )
38
- self.init_task(task_desc, max_steps=max_steps)
49
+ self.init_task(
50
+ task_desc,
51
+ max_steps=max_steps,
52
+ last_task_id=last_task_id,
53
+ history_steps=history_steps,
54
+ )
39
55
 
40
56
  for i in range(max_steps):
41
57
  logger.debug(f"Auto mode step {i + 1}/{max_steps}")
@@ -130,6 +130,8 @@ class SyncClient:
130
130
  task_id: str | None = None,
131
131
  instruction: str | None = None,
132
132
  max_actions: int | None = 5,
133
+ last_task_id: str | None = None,
134
+ history_steps: int | None = None,
133
135
  api_version: str | None = None,
134
136
  ) -> LLMResponse:
135
137
  """
@@ -142,6 +144,8 @@ class SyncClient:
142
144
  task_id: Task ID for continuing existing task
143
145
  instruction: Additional instruction when continuing a session (only works with task_id)
144
146
  max_actions: Maximum number of actions to return (1-20)
147
+ last_task_id: Previous task ID to retrieve history from (only works with task_id)
148
+ history_steps: Number of historical steps to include from last_task_id (default: 1, max: 10)
145
149
  api_version: API version header
146
150
 
147
151
  Returns:
@@ -166,6 +170,10 @@ class SyncClient:
166
170
  payload["instruction"] = instruction
167
171
  if max_actions is not None:
168
172
  payload["max_actions"] = max_actions
173
+ if last_task_id is not None:
174
+ payload["last_task_id"] = last_task_id
175
+ if history_steps is not None:
176
+ payload["history_steps"] = history_steps
169
177
 
170
178
  logger.info(f"Making API request to /v1/message with model: {model}")
171
179
  logger.debug(
@@ -28,10 +28,27 @@ class Task:
28
28
  self.task_id: str | None = None
29
29
  self.task_description: str | None = None
30
30
  self.model = model
31
+ self.last_task_id: str | None = None
32
+ self.history_steps: int | None = None
31
33
 
32
- def init_task(self, task_desc: str, max_steps: int = 5):
33
- """Initialize a new task with the given description."""
34
+ def init_task(
35
+ self,
36
+ task_desc: str,
37
+ max_steps: int = 5,
38
+ last_task_id: str | None = None,
39
+ history_steps: int | None = None,
40
+ ):
41
+ """Initialize a new task with the given description.
42
+
43
+ Args:
44
+ task_desc: Task description
45
+ max_steps: Maximum number of steps (for logging)
46
+ last_task_id: Previous task ID to retrieve history from
47
+ history_steps: Number of historical steps to include (default: 1)
48
+ """
34
49
  self.task_description = task_desc
50
+ self.last_task_id = last_task_id
51
+ self.history_steps = history_steps
35
52
  response = self.client.create_message(
36
53
  model=self.model,
37
54
  screenshot="",
@@ -40,6 +57,10 @@ class Task:
40
57
  )
41
58
  self.task_id = response.task_id # Reset task_id for new task
42
59
  logger.info(f"Task initialized: '{task_desc}' (max_steps: {max_steps})")
60
+ if last_task_id:
61
+ logger.info(
62
+ f"Will include {history_steps or 1} steps from previous task: {last_task_id}"
63
+ )
43
64
 
44
65
  def step(self, screenshot: Image | bytes, instruction: str | None = None) -> Step:
45
66
  """Send screenshot to the server and get the next actions.
@@ -71,6 +92,8 @@ class Task:
71
92
  task_description=self.task_description,
72
93
  task_id=self.task_id,
73
94
  instruction=instruction,
95
+ last_task_id=self.last_task_id if self.task_id else None,
96
+ history_steps=self.history_steps if self.task_id else None,
74
97
  )
75
98
 
76
99
  # Update task_id from response
@@ -196,6 +196,76 @@ class TestCapsLockManager:
196
196
  assert system_manager.should_use_system_capslock() is True
197
197
 
198
198
 
199
+ class TestCornerCoordinatesHandling:
200
+ """Test that corner coordinates are adjusted to prevent PyAutoGUI fail-safe."""
201
+
202
+ @pytest.mark.parametrize(
203
+ "input_coords,expected_coords",
204
+ [
205
+ # Top-left corner
206
+ ("0, 0", (1, 1)),
207
+ ("1, 1", (1, 1)),
208
+ # Top-right corner (assuming 1920x1080 screen)
209
+ ("1000, 0", (1919, 1)),
210
+ ("999, 1", (1918, 1)),
211
+ # Bottom-left corner
212
+ ("0, 1000", (1, 1079)),
213
+ ("1, 999", (1, 1078)),
214
+ # Bottom-right corner
215
+ ("1000, 1000", (1919, 1079)),
216
+ ("999, 999", (1918, 1078)),
217
+ # Middle coordinates should not be affected
218
+ ("500, 500", (960, 540)),
219
+ ("250, 750", (480, 810)),
220
+ ],
221
+ )
222
+ def test_corner_coordinate_adjustment(
223
+ self, mock_pyautogui, input_coords, expected_coords
224
+ ):
225
+ handler = PyautoguiActionHandler()
226
+ action = Action(type=ActionType.CLICK, argument=input_coords, count=1)
227
+ handler([action])
228
+ mock_pyautogui.click.assert_called_once_with(*expected_coords)
229
+
230
+ def test_drag_with_corner_coordinates(self, mock_pyautogui, config):
231
+ """Test drag operations with corner coordinates."""
232
+ handler = PyautoguiActionHandler()
233
+ # Drag from top-left corner to bottom-right corner
234
+ action = Action(type=ActionType.DRAG, argument="0, 0, 1000, 1000", count=1)
235
+ handler([action])
236
+
237
+ # Should adjust corner coordinates to prevent fail-safe
238
+ mock_pyautogui.moveTo.assert_called_once_with(1, 1)
239
+ mock_pyautogui.dragTo.assert_called_once_with(
240
+ 1919, 1079, duration=config.drag_duration, button="left"
241
+ )
242
+
243
+ def test_scroll_with_corner_coordinates(self, mock_pyautogui, config):
244
+ """Test scroll operations at corner coordinates."""
245
+ handler = PyautoguiActionHandler()
246
+ action = Action(type=ActionType.SCROLL, argument="0, 0, up", count=1)
247
+ handler([action])
248
+
249
+ # Should adjust corner coordinates
250
+ mock_pyautogui.moveTo.assert_called_once_with(1, 1)
251
+ mock_pyautogui.scroll.assert_called_once_with(config.scroll_amount)
252
+
253
+ def test_multiple_clicks_at_corners(self, mock_pyautogui):
254
+ """Test multiple clicks at corner positions."""
255
+ handler = PyautoguiActionHandler()
256
+ actions = [
257
+ Action(type=ActionType.LEFT_DOUBLE, argument="0, 0", count=1),
258
+ Action(type=ActionType.LEFT_TRIPLE, argument="1000, 0", count=1),
259
+ Action(type=ActionType.RIGHT_SINGLE, argument="0, 1000", count=1),
260
+ ]
261
+ handler(actions)
262
+
263
+ # All corner coordinates should be adjusted
264
+ mock_pyautogui.doubleClick.assert_called_once_with(1, 1)
265
+ mock_pyautogui.tripleClick.assert_called_once_with(1919, 1)
266
+ mock_pyautogui.rightClick.assert_called_once_with(1, 1079)
267
+
268
+
199
269
  class TestCapsLockIntegration:
200
270
  def test_caps_lock_key_normalization(self, mock_pyautogui):
201
271
  handler = PyautoguiActionHandler()
@@ -19,6 +19,7 @@ from oagi.exceptions import (
19
19
  AuthenticationError,
20
20
  ConfigurationError,
21
21
  RequestTimeoutError,
22
+ ValidationError,
22
23
  )
23
24
  from oagi.sync_client import (
24
25
  ErrorDetail,
@@ -300,6 +301,143 @@ class TestHelperFunctions:
300
301
  mock_open.assert_called_once_with("/path/to/image.png", "rb")
301
302
 
302
303
 
304
+ class TestSyncClientHistory:
305
+ """Test the history functionality of SyncClient."""
306
+
307
+ @pytest.fixture
308
+ def base_client_setup(self):
309
+ """Set up base client with successful response."""
310
+ client = SyncClient(base_url="http://test.com", api_key="test-key")
311
+ with patch.object(client.client, "post") as mock_post:
312
+ mock_response = Mock()
313
+ mock_response.status_code = 200
314
+ mock_response.json.return_value = {
315
+ "id": "resp-123",
316
+ "task_id": "task-456",
317
+ "object": "task.completion",
318
+ "created": 1677652288,
319
+ "model": "vision-model-v1",
320
+ "task_description": "Test task",
321
+ "current_step": 1,
322
+ "is_complete": False,
323
+ "actions": [],
324
+ "usage": {
325
+ "prompt_tokens": 100,
326
+ "completion_tokens": 50,
327
+ "total_tokens": 150,
328
+ },
329
+ }
330
+ mock_post.return_value = mock_response
331
+ yield client, mock_post
332
+
333
+ def test_create_message_with_history(self, base_client_setup):
334
+ """Test create_message with history parameters."""
335
+ client, mock_post = base_client_setup
336
+
337
+ # Call with history parameters
338
+ client.create_message(
339
+ model="vision-model-v1",
340
+ screenshot="base64_image",
341
+ task_id="task-456",
342
+ last_task_id="previous-task",
343
+ history_steps=2,
344
+ )
345
+
346
+ # Verify request payload includes history params
347
+ mock_post.assert_called_once()
348
+ call_args = mock_post.call_args
349
+ payload = call_args[1]["json"]
350
+ assert payload["last_task_id"] == "previous-task"
351
+ assert payload["history_steps"] == 2
352
+
353
+ def test_create_message_without_history(self, base_client_setup):
354
+ """Test create_message without history parameters."""
355
+ client, mock_post = base_client_setup
356
+
357
+ # Call without history parameters
358
+ client.create_message(
359
+ model="vision-model-v1",
360
+ screenshot="base64_image",
361
+ task_id="task-456",
362
+ )
363
+
364
+ # Verify request payload doesn't include history params
365
+ mock_post.assert_called_once()
366
+ call_args = mock_post.call_args
367
+ payload = call_args[1]["json"]
368
+ assert "last_task_id" not in payload
369
+ assert "history_steps" not in payload
370
+
371
+ def test_history_validation_error(self):
372
+ """Test that server validation errors are handled properly."""
373
+ client = SyncClient(base_url="http://test.com", api_key="test-key")
374
+
375
+ with patch.object(client.client, "post") as mock_post:
376
+ # Mock validation error response
377
+ mock_response = Mock()
378
+ mock_response.status_code = 422
379
+ mock_response.json.return_value = {
380
+ "error": {
381
+ "code": "validation_error",
382
+ "message": "last_task_id can only be used when continuing a session with task_id",
383
+ }
384
+ }
385
+ mock_post.return_value = mock_response
386
+
387
+ # Try to use last_task_id without task_id
388
+ with pytest.raises(ValidationError) as exc_info:
389
+ client.create_message(
390
+ model="vision-model-v1",
391
+ screenshot="base64_image",
392
+ task_description="New task",
393
+ last_task_id="previous-task",
394
+ history_steps=1,
395
+ )
396
+
397
+ assert "last_task_id can only be used" in str(exc_info.value)
398
+
399
+ def test_history_with_instruction(self, base_client_setup):
400
+ """Test combining history with instruction."""
401
+ client, mock_post = base_client_setup
402
+
403
+ # Call with both history and instruction
404
+ client.create_message(
405
+ model="vision-model-v1",
406
+ screenshot="base64_image",
407
+ task_id="task-456",
408
+ instruction="Click submit",
409
+ last_task_id="previous-task",
410
+ history_steps=1,
411
+ )
412
+
413
+ # Verify all parameters are included
414
+ mock_post.assert_called_once()
415
+ call_args = mock_post.call_args
416
+ payload = call_args[1]["json"]
417
+ assert payload["instruction"] == "Click submit"
418
+ assert payload["last_task_id"] == "previous-task"
419
+ assert payload["history_steps"] == 1
420
+
421
+ def test_history_default_steps(self, base_client_setup):
422
+ """Test that omitting history_steps works."""
423
+ client, mock_post = base_client_setup
424
+
425
+ # Call with only last_task_id
426
+ client.create_message(
427
+ model="vision-model-v1",
428
+ screenshot="base64_image",
429
+ task_id="task-456",
430
+ last_task_id="previous-task",
431
+ )
432
+
433
+ # Verify request
434
+ mock_post.assert_called_once()
435
+ call_args = mock_post.call_args
436
+ payload = call_args[1]["json"]
437
+ assert payload["last_task_id"] == "previous-task"
438
+ assert "history_steps" not in payload # None values aren't sent
439
+
440
+
303
441
  class TestTraceLogging:
304
442
  @pytest.mark.parametrize(
305
443
  "trace_headers,expected_logs",
@@ -108,6 +108,8 @@ class TestStep:
108
108
  task_description="Test task",
109
109
  task_id="existing-task",
110
110
  instruction=None,
111
+ last_task_id=None,
112
+ history_steps=None,
111
113
  )
112
114
 
113
115
  # Verify returned Step
@@ -140,6 +142,8 @@ class TestStep:
140
142
  task_description="Test task",
141
143
  task_id=None,
142
144
  instruction=None,
145
+ last_task_id=None,
146
+ history_steps=None,
143
147
  )
144
148
 
145
149
  # Verify task_id was updated
@@ -209,6 +213,8 @@ class TestStep:
209
213
  task_description="Test task",
210
214
  task_id="existing-task",
211
215
  instruction="Click the submit button",
216
+ last_task_id=None,
217
+ history_steps=None,
212
218
  )
213
219
 
214
220
  assert isinstance(result, Step)
@@ -281,3 +287,147 @@ class TestIntegrationScenarios:
281
287
  # Verify second call used the task_id
282
288
  calls = task.client.create_message.call_args_list
283
289
  assert calls[1][1]["task_id"] == "task-456"
290
+
291
+
292
+ class TestTaskHistory:
293
+ """Test Task class history functionality."""
294
+
295
+ def test_init_task_with_history(self, task, sample_llm_response):
296
+ """Test init_task with history parameters."""
297
+ task.client.create_message.return_value = sample_llm_response
298
+
299
+ # Initialize task with history
300
+ task.init_task(
301
+ "Test task",
302
+ max_steps=5,
303
+ last_task_id="previous-task",
304
+ history_steps=2,
305
+ )
306
+
307
+ # Verify task attributes
308
+ assert task.task_description == "Test task"
309
+ assert task.task_id == "task-456"
310
+ assert task.last_task_id == "previous-task"
311
+ assert task.history_steps == 2
312
+
313
+ # History is not sent during init, only stored
314
+ task.client.create_message.assert_called_once_with(
315
+ model="vision-model-v1",
316
+ screenshot="",
317
+ task_description="Test task",
318
+ task_id=None,
319
+ )
320
+
321
+ def test_init_task_without_history(self, task, sample_llm_response):
322
+ """Test init_task without history parameters."""
323
+ task.client.create_message.return_value = sample_llm_response
324
+
325
+ # Initialize task without history
326
+ task.init_task("Test task", max_steps=5)
327
+
328
+ # Verify task attributes
329
+ assert task.task_description == "Test task"
330
+ assert task.task_id == "task-456"
331
+ assert task.last_task_id is None
332
+ assert task.history_steps is None
333
+
334
+ def test_step_with_history(self, task, sample_llm_response):
335
+ """Test step method with history (continuing session)."""
336
+ task.task_description = "Test task"
337
+ task.task_id = "task-456" # Already have task_id
338
+ task.last_task_id = "previous-task"
339
+ task.history_steps = 2
340
+ task.client.create_message.return_value = sample_llm_response
341
+
342
+ with patch("oagi.task.encode_screenshot_from_bytes") as mock_encode:
343
+ mock_encode.return_value = "base64_encoded"
344
+
345
+ # Call step
346
+ task.step(b"screenshot_data", instruction="Click submit")
347
+
348
+ # Verify API call includes history params
349
+ task.client.create_message.assert_called_once_with(
350
+ model="vision-model-v1",
351
+ screenshot="base64_encoded",
352
+ task_description="Test task",
353
+ task_id="task-456",
354
+ instruction="Click submit",
355
+ last_task_id="previous-task",
356
+ history_steps=2,
357
+ )
358
+
359
+ def test_step_history_only_when_continuing(self, task, sample_llm_response):
360
+ """Test that history is only sent when continuing (task_id exists)."""
361
+ task.task_description = "Test task"
362
+ task.last_task_id = "previous-task"
363
+ task.history_steps = 1
364
+ task.client.create_message.return_value = sample_llm_response
365
+
366
+ with patch("oagi.task.encode_screenshot_from_bytes") as mock_encode:
367
+ mock_encode.return_value = "base64_encoded"
368
+
369
+ # First step - no task_id yet
370
+ task.task_id = None
371
+ task.step(b"screenshot1")
372
+
373
+ # Verify first call - no history
374
+ first_call = task.client.create_message.call_args_list[0][1]
375
+ assert first_call["task_id"] is None
376
+ assert first_call["last_task_id"] is None
377
+ assert first_call["history_steps"] is None
378
+
379
+ # Task ID should be updated
380
+ assert task.task_id == "task-456"
381
+
382
+ # Second step - now have task_id
383
+ task.step(b"screenshot2")
384
+
385
+ # Verify second call - includes history
386
+ second_call = task.client.create_message.call_args_list[1][1]
387
+ assert second_call["task_id"] == "task-456"
388
+ assert second_call["last_task_id"] == "previous-task"
389
+ assert second_call["history_steps"] == 1
390
+
391
+ def test_init_task_with_default_history_steps(self, task, sample_llm_response):
392
+ """Test that history_steps can be omitted."""
393
+ task.client.create_message.return_value = sample_llm_response
394
+
395
+ # Initialize task with last_task_id but no history_steps
396
+ task.init_task(
397
+ "Test task",
398
+ max_steps=5,
399
+ last_task_id="previous-task",
400
+ # history_steps not specified
401
+ )
402
+
403
+ # Verify task attributes
404
+ assert task.last_task_id == "previous-task"
405
+ assert task.history_steps is None # Not specified
406
+
407
+ def test_step_without_history(self, task, sample_llm_response):
408
+ """Test step method without history (first step)."""
409
+ task.task_description = "Test task"
410
+ task.task_id = None # First step
411
+ task.client.create_message.return_value = sample_llm_response
412
+
413
+ with patch("oagi.task.encode_screenshot_from_bytes") as mock_encode:
414
+ mock_encode.return_value = "base64_encoded"
415
+
416
+ # Call step
417
+ result = task.step(b"screenshot_data")
418
+
419
+ # Verify API call - no history params on first step
420
+ task.client.create_message.assert_called_once_with(
421
+ model="vision-model-v1",
422
+ screenshot="base64_encoded",
423
+ task_description="Test task",
424
+ task_id=None,
425
+ instruction=None,
426
+ last_task_id=None,
427
+ history_steps=None,
428
+ )
429
+
430
+ # Verify result
431
+ assert isinstance(result, Step)
432
+ assert not result.stop
433
+ assert len(result.actions) == 2
@@ -138,7 +138,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/28/fa/b2ba8229b9381e8f6
138
138
 
139
139
  [[package]]
140
140
  name = "oagi"
141
- version = "0.4.2"
141
+ version = "0.5.0"
142
142
  source = { editable = "." }
143
143
  dependencies = [
144
144
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes