oagi-core 0.10.3__py3-none-any.whl → 0.12.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 (44) hide show
  1. oagi/__init__.py +1 -3
  2. oagi/actor/__init__.py +21 -0
  3. oagi/{task → actor}/async_.py +23 -7
  4. oagi/{task → actor}/async_short.py +1 -1
  5. oagi/actor/base.py +222 -0
  6. oagi/{task → actor}/short.py +1 -1
  7. oagi/{task → actor}/sync.py +21 -5
  8. oagi/agent/default.py +5 -0
  9. oagi/agent/factories.py +75 -3
  10. oagi/agent/observer/exporters.py +6 -0
  11. oagi/agent/observer/report_template.html +19 -0
  12. oagi/agent/tasker/planner.py +31 -19
  13. oagi/agent/tasker/taskee_agent.py +26 -7
  14. oagi/agent/tasker/tasker_agent.py +4 -0
  15. oagi/cli/agent.py +54 -30
  16. oagi/client/async_.py +54 -96
  17. oagi/client/base.py +81 -133
  18. oagi/client/sync.py +52 -99
  19. oagi/constants.py +7 -2
  20. oagi/handler/__init__.py +16 -0
  21. oagi/handler/_macos.py +137 -0
  22. oagi/handler/_windows.py +101 -0
  23. oagi/handler/async_pyautogui_action_handler.py +8 -0
  24. oagi/handler/capslock_manager.py +55 -0
  25. oagi/handler/pyautogui_action_handler.py +21 -39
  26. oagi/server/session_store.py +3 -3
  27. oagi/server/socketio_server.py +4 -4
  28. oagi/task/__init__.py +22 -8
  29. oagi/types/__init__.py +2 -1
  30. oagi/types/models/__init__.py +0 -2
  31. oagi/types/models/action.py +4 -1
  32. oagi/types/models/client.py +1 -17
  33. oagi/types/step_observer.py +2 -0
  34. oagi/types/url.py +25 -0
  35. oagi/utils/__init__.py +12 -0
  36. oagi/utils/output_parser.py +166 -0
  37. oagi/utils/prompt_builder.py +44 -0
  38. {oagi_core-0.10.3.dist-info → oagi_core-0.12.0.dist-info}/METADATA +90 -10
  39. oagi_core-0.12.0.dist-info/RECORD +76 -0
  40. oagi/task/base.py +0 -158
  41. oagi_core-0.10.3.dist-info/RECORD +0 -70
  42. {oagi_core-0.10.3.dist-info → oagi_core-0.12.0.dist-info}/WHEEL +0 -0
  43. {oagi_core-0.10.3.dist-info → oagi_core-0.12.0.dist-info}/entry_points.txt +0 -0
  44. {oagi_core-0.10.3.dist-info → oagi_core-0.12.0.dist-info}/licenses/LICENSE +0 -0
oagi/__init__.py CHANGED
@@ -8,6 +8,7 @@
8
8
  import importlib
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from oagi.actor import Actor, AsyncActor, AsyncShortTask, AsyncTask, ShortTask, Task
11
12
  from oagi.client import AsyncClient, SyncClient
12
13
  from oagi.exceptions import (
13
14
  APIError,
@@ -22,13 +23,11 @@ from oagi.exceptions import (
22
23
  ValidationError,
23
24
  check_optional_dependency,
24
25
  )
25
- from oagi.task import Actor, AsyncActor, AsyncShortTask, AsyncTask, ShortTask, Task
26
26
  from oagi.types import ImageConfig
27
27
  from oagi.types.models import (
28
28
  ErrorDetail,
29
29
  ErrorResponse,
30
30
  GenerateResponse,
31
- LLMResponse,
32
31
  UploadFileResponse,
33
32
  )
34
33
 
@@ -116,7 +115,6 @@ __all__ = [
116
115
  # Configuration
117
116
  "ImageConfig",
118
117
  # Response models
119
- "LLMResponse",
120
118
  "GenerateResponse",
121
119
  "UploadFileResponse",
122
120
  "ErrorResponse",
oagi/actor/__init__.py ADDED
@@ -0,0 +1,21 @@
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 .async_ import AsyncActor, AsyncTask
10
+ from .async_short import AsyncShortTask
11
+ from .short import ShortTask
12
+ from .sync import Actor, Task
13
+
14
+ __all__ = [
15
+ "Actor",
16
+ "AsyncActor",
17
+ "Task", # Deprecated: Use Actor instead
18
+ "AsyncTask", # Deprecated: Use AsyncActor instead
19
+ "ShortTask", # Deprecated
20
+ "AsyncShortTask", # Deprecated
21
+ ]
@@ -10,9 +10,12 @@ import warnings
10
10
 
11
11
  from ..client import AsyncClient
12
12
  from ..constants import DEFAULT_MAX_STEPS, MODEL_ACTOR
13
+ from ..logging import get_logger
13
14
  from ..types import URL, Image, Step
14
15
  from .base import BaseActor
15
16
 
17
+ logger = get_logger("actor.async")
18
+
16
19
 
17
20
  class AsyncActor(BaseActor):
18
21
  """Async base class for task automation with the OAGI API."""
@@ -51,20 +54,33 @@ class AsyncActor(BaseActor):
51
54
  """Send screenshot to the server and get the next actions.
52
55
 
53
56
  Args:
54
- screenshot: Screenshot as Image object or raw bytes
55
- instruction: Optional additional instruction for this step
57
+ screenshot: Screenshot as Image object, URL string, or raw bytes
58
+ instruction: Optional additional instruction for this step (currently unused)
56
59
  temperature: Sampling temperature for this step (overrides task default if provided)
57
60
 
58
61
  Returns:
59
62
  Step: The actions and reasoning for this step
60
63
  """
61
- kwargs = self._prepare_step(
62
- screenshot, instruction, temperature, prefix="async "
63
- )
64
+ self._validate_and_increment_step()
65
+ self._log_step_execution(prefix="async ")
64
66
 
65
67
  try:
66
- response = await self.client.create_message(**kwargs)
67
- return self._build_step_response(response, prefix="Async ")
68
+ screenshot_url = await self._ensure_screenshot_url_async(
69
+ screenshot, self.client
70
+ )
71
+ self._add_user_message_to_history(screenshot_url, self._build_step_prompt())
72
+
73
+ step, raw_output, usage = await self.client.chat_completion(
74
+ model=self.model,
75
+ messages=self.message_history,
76
+ temperature=self._get_temperature(temperature),
77
+ task_id=self.task_id,
78
+ )
79
+
80
+ self._add_assistant_message_to_history(raw_output)
81
+ self._log_step_completion(step, prefix="Async ")
82
+ return step
83
+
68
84
  except Exception as e:
69
85
  self._handle_step_error(e, prefix="async ")
70
86
 
@@ -14,7 +14,7 @@ from ..types import AsyncActionHandler, AsyncImageProvider
14
14
  from .async_ import AsyncActor
15
15
  from .base import BaseAutoMode
16
16
 
17
- logger = get_logger("async_short_task")
17
+ logger = get_logger("async_short_actor")
18
18
 
19
19
 
20
20
  class AsyncShortTask(AsyncActor, BaseAutoMode):
oagi/actor/base.py ADDED
@@ -0,0 +1,222 @@
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 uuid import uuid4
10
+
11
+ from ..constants import (
12
+ DEFAULT_MAX_STEPS,
13
+ MAX_STEPS_ACTOR,
14
+ MAX_STEPS_THINKER,
15
+ MODEL_THINKER,
16
+ )
17
+ from ..logging import get_logger
18
+ from ..types import URL, Image, Step
19
+ from ..utils.prompt_builder import build_prompt
20
+
21
+ logger = get_logger("actor.base")
22
+
23
+
24
+ class BaseActor:
25
+ """Base class with shared task management logic for sync/async actors."""
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None,
30
+ base_url: str | None,
31
+ model: str,
32
+ temperature: float | None,
33
+ ):
34
+ self.task_id: str = uuid4().hex # Client-side generated UUID
35
+ self.task_description: str | None = None
36
+ self.model = model
37
+ self.temperature = temperature
38
+ self.message_history: list = [] # OpenAI-compatible message history
39
+ self.max_steps: int = DEFAULT_MAX_STEPS
40
+ self.current_step: int = 0 # Current step counter
41
+ # Client will be set by subclasses
42
+ self.api_key: str | None = None
43
+ self.base_url: str | None = None
44
+
45
+ def _validate_max_steps(self, max_steps: int) -> int:
46
+ """Validate and cap max_steps based on model type.
47
+
48
+ Args:
49
+ max_steps: Requested maximum number of steps
50
+
51
+ Returns:
52
+ Validated max_steps (capped to model limit if exceeded)
53
+ """
54
+ limit = MAX_STEPS_THINKER if self.model == MODEL_THINKER else MAX_STEPS_ACTOR
55
+ if max_steps > limit:
56
+ logger.warning(
57
+ f"max_steps ({max_steps}) exceeds limit for model '{self.model}'. "
58
+ f"Capping to {limit}."
59
+ )
60
+ return limit
61
+ return max_steps
62
+
63
+ def _prepare_init_task(
64
+ self,
65
+ task_desc: str,
66
+ max_steps: int,
67
+ ):
68
+ """Prepare task initialization.
69
+
70
+ Args:
71
+ task_desc: Task description
72
+ max_steps: Maximum number of steps
73
+ """
74
+ self.task_id = uuid4().hex
75
+ self.task_description = task_desc
76
+ self.message_history = []
77
+ self.max_steps = self._validate_max_steps(max_steps)
78
+ self.current_step = 0
79
+ logger.info(f"Task initialized: '{task_desc}' (max_steps: {self.max_steps})")
80
+
81
+ def _validate_and_increment_step(self):
82
+ if not self.task_description:
83
+ raise ValueError("Task description must be set. Call init_task() first.")
84
+ if self.current_step >= self.max_steps:
85
+ raise ValueError(
86
+ f"Max steps limit ({self.max_steps}) reached. "
87
+ "Call init_task() to start a new task."
88
+ )
89
+ self.current_step += 1
90
+
91
+ def _get_temperature(self, temperature: float | None) -> float | None:
92
+ return temperature if temperature is not None else self.temperature
93
+
94
+ def _prepare_screenshot(self, screenshot: Image | bytes) -> bytes:
95
+ if isinstance(screenshot, Image):
96
+ return screenshot.read()
97
+ return screenshot
98
+
99
+ def _get_screenshot_url(self, screenshot: Image | URL | bytes) -> str | None:
100
+ """Get screenshot URL if it's a string, otherwise return None."""
101
+ if isinstance(screenshot, str):
102
+ return screenshot
103
+ return None
104
+
105
+ def _ensure_screenshot_url_sync(
106
+ self, screenshot: Image | URL | bytes, client
107
+ ) -> str:
108
+ """Get screenshot URL, uploading to S3 if needed (sync version).
109
+
110
+ Args:
111
+ screenshot: Screenshot as Image object, URL string, or raw bytes
112
+ client: SyncClient instance for S3 upload
113
+
114
+ Returns:
115
+ Screenshot URL (either direct or from S3 upload)
116
+ """
117
+ screenshot_url = self._get_screenshot_url(screenshot)
118
+ if screenshot_url is None:
119
+ screenshot_bytes = self._prepare_screenshot(screenshot)
120
+ upload_response = client.put_s3_presigned_url(screenshot_bytes)
121
+ screenshot_url = upload_response.download_url
122
+ return screenshot_url
123
+
124
+ async def _ensure_screenshot_url_async(
125
+ self, screenshot: Image | URL | bytes, client
126
+ ) -> str:
127
+ """Get screenshot URL, uploading to S3 if needed (async version).
128
+
129
+ Args:
130
+ screenshot: Screenshot as Image object, URL string, or raw bytes
131
+ client: AsyncClient instance for S3 upload
132
+
133
+ Returns:
134
+ Screenshot URL (either direct or from S3 upload)
135
+ """
136
+ screenshot_url = self._get_screenshot_url(screenshot)
137
+ if screenshot_url is None:
138
+ screenshot_bytes = self._prepare_screenshot(screenshot)
139
+ upload_response = await client.put_s3_presigned_url(screenshot_bytes)
140
+ screenshot_url = upload_response.download_url
141
+ return screenshot_url
142
+
143
+ def _add_user_message_to_history(
144
+ self, screenshot_url: str, prompt: str | None = None
145
+ ):
146
+ """Add user message with screenshot to message history.
147
+
148
+ Args:
149
+ screenshot_url: URL of the screenshot
150
+ prompt: Optional prompt text (for first message only)
151
+ """
152
+ content = []
153
+ if prompt:
154
+ content.append({"type": "text", "text": prompt})
155
+ content.append({"type": "image_url", "image_url": {"url": screenshot_url}})
156
+
157
+ self.message_history.append(
158
+ {
159
+ "role": "user",
160
+ "content": content,
161
+ }
162
+ )
163
+
164
+ def _add_assistant_message_to_history(self, raw_output: str):
165
+ """Add assistant response to message history.
166
+
167
+ Args:
168
+ raw_output: Raw model output string
169
+ """
170
+ if raw_output:
171
+ self.message_history.append(
172
+ {
173
+ "role": "assistant",
174
+ "content": raw_output,
175
+ }
176
+ )
177
+
178
+ def _build_step_prompt(self) -> str | None:
179
+ """Build prompt for first message only."""
180
+ if len(self.message_history) == 0:
181
+ return build_prompt(self.task_description)
182
+ return None
183
+
184
+ def _log_step_completion(self, step: Step, prefix: str = "") -> None:
185
+ """Log step completion status."""
186
+ if step.stop:
187
+ logger.info(f"{prefix}Task completed.")
188
+ else:
189
+ logger.debug(f"{prefix}Step completed with {len(step.actions)} actions")
190
+
191
+ def _log_step_execution(self, prefix: str = ""):
192
+ logger.debug(f"Executing {prefix}step for task: '{self.task_description}'")
193
+
194
+ def _handle_step_error(self, error: Exception, prefix: str = ""):
195
+ logger.error(f"Error during {prefix}step execution: {error}")
196
+ raise
197
+
198
+
199
+ class BaseAutoMode:
200
+ """Base class with shared auto_mode logic for ShortTask implementations."""
201
+
202
+ def _log_auto_mode_start(self, task_desc: str, max_steps: int, prefix: str = ""):
203
+ logger.info(
204
+ f"Starting {prefix}auto mode for task: '{task_desc}' (max_steps: {max_steps})"
205
+ )
206
+
207
+ def _log_auto_mode_step(self, step_num: int, max_steps: int, prefix: str = ""):
208
+ logger.debug(f"{prefix.capitalize()}auto mode step {step_num}/{max_steps}")
209
+
210
+ def _log_auto_mode_actions(self, action_count: int, prefix: str = ""):
211
+ verb = "asynchronously" if "async" in prefix else ""
212
+ logger.debug(f"Executing {action_count} actions {verb}".strip())
213
+
214
+ def _log_auto_mode_completion(self, steps: int, prefix: str = ""):
215
+ logger.info(
216
+ f"{prefix.capitalize()}auto mode completed successfully after {steps} steps"
217
+ )
218
+
219
+ def _log_auto_mode_max_steps(self, max_steps: int, prefix: str = ""):
220
+ logger.warning(
221
+ f"{prefix.capitalize()}auto mode reached max steps ({max_steps}) without completion"
222
+ )
@@ -14,7 +14,7 @@ from ..types import ActionHandler, ImageProvider
14
14
  from .base import BaseAutoMode
15
15
  from .sync import Actor
16
16
 
17
- logger = get_logger("short_task")
17
+ logger = get_logger("short_actor")
18
18
 
19
19
 
20
20
  class ShortTask(Actor, BaseAutoMode):
@@ -10,9 +10,12 @@ import warnings
10
10
 
11
11
  from ..client import SyncClient
12
12
  from ..constants import DEFAULT_MAX_STEPS, MODEL_ACTOR
13
+ from ..logging import get_logger
13
14
  from ..types import URL, Image, Step
14
15
  from .base import BaseActor
15
16
 
17
+ logger = get_logger("actor.sync")
18
+
16
19
 
17
20
  class Actor(BaseActor):
18
21
  """Base class for task automation with the OAGI API."""
@@ -51,18 +54,31 @@ class Actor(BaseActor):
51
54
  """Send screenshot to the server and get the next actions.
52
55
 
53
56
  Args:
54
- screenshot: Screenshot as Image object or raw bytes
55
- instruction: Optional additional instruction for this step
57
+ screenshot: Screenshot as Image object, URL string, or raw bytes
58
+ instruction: Optional additional instruction for this step (currently unused)
56
59
  temperature: Sampling temperature for this step (overrides task default if provided)
57
60
 
58
61
  Returns:
59
62
  Step: The actions and reasoning for this step
60
63
  """
61
- kwargs = self._prepare_step(screenshot, instruction, temperature)
64
+ self._validate_and_increment_step()
65
+ self._log_step_execution()
62
66
 
63
67
  try:
64
- response = self.client.create_message(**kwargs)
65
- return self._build_step_response(response)
68
+ screenshot_url = self._ensure_screenshot_url_sync(screenshot, self.client)
69
+ self._add_user_message_to_history(screenshot_url, self._build_step_prompt())
70
+
71
+ step, raw_output, usage = self.client.chat_completion(
72
+ model=self.model,
73
+ messages=self.message_history,
74
+ temperature=self._get_temperature(temperature),
75
+ task_id=self.task_id,
76
+ )
77
+
78
+ self._add_assistant_message_to_history(raw_output)
79
+ self._log_step_completion(step)
80
+ return step
81
+
66
82
  except Exception as e:
67
83
  self._handle_step_error(e)
68
84
 
oagi/agent/default.py CHANGED
@@ -16,6 +16,7 @@ from ..constants import (
16
16
  DEFAULT_TEMPERATURE,
17
17
  MODEL_ACTOR,
18
18
  )
19
+ from ..handler import reset_handler
19
20
  from ..types import (
20
21
  ActionEvent,
21
22
  AsyncActionHandler,
@@ -68,6 +69,9 @@ class AsyncDefaultAgent:
68
69
  logger.info(f"Starting async task execution: {instruction}")
69
70
  await self.actor.init_task(instruction, max_steps=self.max_steps)
70
71
 
72
+ # Reset handler state at automation start
73
+ reset_handler(action_handler)
74
+
71
75
  for i in range(self.max_steps):
72
76
  step_num = i + 1
73
77
  logger.debug(f"Executing step {step_num}/{self.max_steps}")
@@ -89,6 +93,7 @@ class AsyncDefaultAgent:
89
93
  step_num=step_num,
90
94
  image=_serialize_image(image),
91
95
  step=step,
96
+ task_id=self.actor.task_id,
92
97
  )
93
98
  )
94
99
 
oagi/agent/factories.py CHANGED
@@ -65,8 +65,8 @@ def create_thinker_agent(
65
65
  )
66
66
 
67
67
 
68
- @async_agent_register(mode="tasker")
69
- def create_planner_agent(
68
+ @async_agent_register(mode="tasker:cvs_appointment")
69
+ def create_cvs_appointment_agent(
70
70
  api_key: str | None = None,
71
71
  base_url: str | None = None,
72
72
  model: str = MODEL_ACTOR,
@@ -75,6 +75,12 @@ def create_planner_agent(
75
75
  reflection_interval: int = DEFAULT_REFLECTION_INTERVAL_TASKER,
76
76
  step_observer: AsyncStepObserver | None = None,
77
77
  step_delay: float = DEFAULT_STEP_DELAY,
78
+ # CVS-specific parameters
79
+ first_name: str = "First",
80
+ last_name: str = "Last",
81
+ email: str = "user@example.com",
82
+ birthday: str = "01-01-1990", # MM-DD-YYYY
83
+ zip_code: str = "00000",
78
84
  ) -> AsyncAgent:
79
85
  tasker = TaskerAgent(
80
86
  api_key=api_key,
@@ -86,5 +92,71 @@ def create_planner_agent(
86
92
  step_observer=step_observer,
87
93
  step_delay=step_delay,
88
94
  )
89
- # tasker.set_task()
95
+
96
+ month, day, year = birthday.split("-")
97
+ instruction = (
98
+ f"Schedule an appointment at CVS for {first_name} {last_name} "
99
+ f"with email {email} and birthday {birthday}"
100
+ )
101
+ todos = [
102
+ "Open a new tab, go to www.cvs.com, type 'flu shot' in the search bar and press enter, "
103
+ "wait for the page to load, then click on the button of Schedule vaccinations on the "
104
+ "top of the page",
105
+ f"Enter the first name '{first_name}', last name '{last_name}', and email '{email}' "
106
+ "in the form. Do not use any suggested autofills. Make sure the mobile phone number "
107
+ "is empty.",
108
+ f"Slightly scroll down to see the date of birth, enter Month '{month}', Day '{day}', "
109
+ f"and Year '{year}' in the form",
110
+ "Click on 'Continue as guest' button, wait for the page to load with wait, "
111
+ "click on 'Add vaccines' button, select 'Flu' and click on 'Add vaccines'",
112
+ f"Click on 'next' to enter the page with recommendation vaccines, then click on "
113
+ f"'next' again, until on the page of entering zip code, enter '{zip_code}', select "
114
+ "the first option from the dropdown menu, and click on 'Search'",
115
+ ]
116
+
117
+ tasker.set_task(instruction, todos)
118
+ return tasker
119
+
120
+
121
+ @async_agent_register(mode="tasker:software_qa")
122
+ def create_software_qa_agent(
123
+ api_key: str | None = None,
124
+ base_url: str | None = None,
125
+ model: str = MODEL_ACTOR,
126
+ max_steps: int = DEFAULT_MAX_STEPS_TASKER,
127
+ temperature: float = DEFAULT_TEMPERATURE_LOW,
128
+ reflection_interval: int = DEFAULT_REFLECTION_INTERVAL_TASKER,
129
+ step_observer: AsyncStepObserver | None = None,
130
+ step_delay: float = DEFAULT_STEP_DELAY,
131
+ ) -> AsyncAgent:
132
+ tasker = TaskerAgent(
133
+ api_key=api_key,
134
+ base_url=base_url,
135
+ model=model,
136
+ max_steps=max_steps,
137
+ temperature=temperature,
138
+ reflection_interval=reflection_interval,
139
+ step_observer=step_observer,
140
+ step_delay=step_delay,
141
+ )
142
+
143
+ instruction = "QA: click through every sidebar button in the Nuclear Player UI"
144
+ todos = [
145
+ "Click on 'Dashboard' in the left sidebar",
146
+ "Click on 'Downloads' in the left sidebar",
147
+ "Click on 'Lyrics' in the left sidebar",
148
+ "Click on 'Plugins' in the left sidebar",
149
+ "Click on 'Search Results' in the left sidebar",
150
+ "Click on 'Settings' in the left sidebar",
151
+ "Click on 'Equalizer' in the left sidebar",
152
+ "Click on 'Visualizer' in the left sidebar",
153
+ "Click on 'Listening History' in the left sidebar",
154
+ "Click on 'Favorite Albums' in the left sidebar",
155
+ "Click on 'Favorite Tracks' in the left sidebar",
156
+ "Click on 'Favorite Artists' in the left sidebar",
157
+ "Click on 'Local Library' in the left sidebar",
158
+ "Click on 'Playlists' in the left sidebar",
159
+ ]
160
+
161
+ tasker.set_task(instruction, todos)
90
162
  return tasker
@@ -98,6 +98,8 @@ def export_to_markdown(
98
98
  case StepEvent():
99
99
  lines.append(f"\n## Step {event.step_num}\n")
100
100
  lines.append(f"**Time:** {timestamp}\n")
101
+ if event.task_id:
102
+ lines.append(f"**Task ID:** `{event.task_id}`\n")
101
103
 
102
104
  if isinstance(event.image, bytes):
103
105
  if images_dir:
@@ -159,6 +161,8 @@ def export_to_markdown(
159
161
  }
160
162
  phase_title = phase_titles.get(event.phase, event.phase.capitalize())
161
163
  lines.append(f"\n### {phase_title} ({timestamp})\n")
164
+ if event.request_id:
165
+ lines.append(f"**Request ID:** `{event.request_id}`\n")
162
166
 
163
167
  if event.image:
164
168
  if isinstance(event.image, bytes):
@@ -227,6 +231,7 @@ def _convert_events_for_html(events: list[ObserverEvent]) -> list[dict]:
227
231
  "reason": event.step.reason,
228
232
  "actions": actions_list,
229
233
  "stop": event.step.stop,
234
+ "task_id": event.task_id,
230
235
  }
231
236
  )
232
237
 
@@ -275,6 +280,7 @@ def _convert_events_for_html(events: list[ObserverEvent]) -> list[dict]:
275
280
  "image": image_data,
276
281
  "reasoning": event.reasoning,
277
282
  "result": event.result,
283
+ "request_id": event.request_id,
278
284
  }
279
285
  )
280
286
 
@@ -46,6 +46,19 @@
46
46
  font-size: 0.9em;
47
47
  }
48
48
 
49
+ .task-id, .request-id {
50
+ color: #666;
51
+ font-size: 0.9em;
52
+ margin-left: 10px;
53
+ }
54
+
55
+ .task-id code, .request-id code {
56
+ background: #e9ecef;
57
+ padding: 2px 6px;
58
+ border-radius: 3px;
59
+ font-family: monospace;
60
+ }
61
+
49
62
  .screenshot-container {
50
63
  position: relative;
51
64
  display: inline-block;
@@ -339,6 +352,9 @@
339
352
  html += '<div class="step">';
340
353
  html += `<h2>Step ${event.step_num}</h2>`;
341
354
  html += `<span class="timestamp">${timestamp}</span>`;
355
+ if (event.task_id) {
356
+ html += ` <span class="task-id">Task ID: <code>${event.task_id}</code></span>`;
357
+ }
342
358
 
343
359
  if (event.image) {
344
360
  const actionsJson = JSON.stringify(event.action_coords || []).replace(/"/g, '&quot;');
@@ -409,6 +425,9 @@
409
425
  html += '<div class="plan">';
410
426
  html += `<h3>${phaseTitle}</h3>`;
411
427
  html += `<span class="timestamp">${timestamp}</span>`;
428
+ if (event.request_id) {
429
+ html += ` <span class="request-id">Request ID: <code>${event.request_id}</code></span>`;
430
+ }
412
431
 
413
432
  if (event.image) {
414
433
  html += '<div class="screenshot-container">';