Skip to content

Commit dcb16c7

Browse files
committed
Improve OpenAI CUA template logging ergonomics and local runner parity.
This adds CUA-style backend/action event rendering (with JSONL mode support), aligns dotenv/local-run behavior across TypeScript and Python templates, and renames local entry scripts to run_local for clearer usage. Made-with: Cursor
1 parent 415546a commit dcb16c7

17 files changed

Lines changed: 1532 additions & 257 deletions

File tree

pkg/templates/python/openai-computer-use/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@ You can test against a remote Kernel browser without deploying:
1111
```bash
1212
cp .env.example .env
1313
# Fill in OPENAI_API_KEY and KERNEL_API_KEY in .env
14-
uv run test_local.py
14+
uv run run_local.py
15+
# JSONL event output
16+
uv run run_local.py --output jsonl
1517
```
1618

19+
The local runner defaults to concise CUA-style logs (`text`), including `kernel>` backend SDK call lines with elapsed timing and `agent>` model output lines. Use `--output jsonl` for one structured event per line (including backend events). Add `--debug` to include verbose in-flight events.
20+
1721
## Deploy to Kernel
1822

1923
```bash
2024
kernel deploy main.py --env-file .env
2125
kernel invoke python-openai-cua cua-task -p '{"task":"go to https://news.ycombinator.com and list top 5 articles"}'
26+
# JSONL logs for invocation
27+
kernel invoke python-openai-cua cua-task -p '{"task":"go to https://news.ycombinator.com and list top 5 articles","output":"jsonl"}'
2228
```
2329

2430
See the [docs](https://www.kernel.sh/docs/quickstart) for more information.

pkg/templates/python/openai-computer-use/agent/agent.py

Lines changed: 180 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
2-
from typing import Callable
2+
import time
3+
from typing import Any, Callable
34
from computers.kernel_computer import KernelComputer
45
from utils import (
56
create_response,
@@ -80,13 +81,15 @@ def __init__(
8081
self.print_steps = True
8182
self.debug = False
8283
self.show_images = False
84+
self.on_event: Callable[[dict], None] | None = None
85+
self._model_request_started_at: float | None = None
8386
self.acknowledge_safety_check_callback = acknowledge_safety_check_callback
8487

8588
if computer:
8689
dimensions = computer.get_dimensions()
8790
self.tools += [
8891
{
89-
"type": "computer-preview",
92+
"type": "computer_use_preview",
9093
"display_width": dimensions[0],
9194
"display_height": dimensions[1],
9295
"environment": computer.get_environment(),
@@ -126,6 +129,86 @@ def debug_print(self, *args):
126129
if self.debug:
127130
pp(*args)
128131

132+
def _emit_event(self, event: str, data: dict | None = None) -> None:
133+
if self.print_steps and self.on_event:
134+
self.on_event({"event": event, "data": data or {}})
135+
136+
def _current_model_elapsed_ms(self) -> int | None:
137+
if self._model_request_started_at is None:
138+
return None
139+
return int((time.time() - self._model_request_started_at) * 1000)
140+
141+
def _extract_reasoning_text(self, item: dict[str, Any]) -> str:
142+
summary = item.get("summary")
143+
if not isinstance(summary, list):
144+
return ""
145+
pieces: list[str] = []
146+
for part in summary:
147+
if not isinstance(part, dict):
148+
continue
149+
text = part.get("text")
150+
if isinstance(text, str) and text:
151+
pieces.append(text)
152+
return " ".join(pieces).strip()
153+
154+
def _extract_prompt_text(self, item: dict[str, Any]) -> str | None:
155+
if item.get("role") != "user":
156+
return None
157+
content = item.get("content")
158+
if isinstance(content, str):
159+
return content
160+
if not isinstance(content, list):
161+
return None
162+
parts: list[str] = []
163+
for entry in content:
164+
if not isinstance(entry, dict):
165+
continue
166+
text = entry.get("text")
167+
if isinstance(text, str) and text:
168+
parts.append(text)
169+
return " ".join(parts) if parts else None
170+
171+
def _describe_action(self, action_type: str, action_args: dict[str, Any]) -> str:
172+
if action_type == "click":
173+
x = int(action_args.get("x", 0))
174+
y = int(action_args.get("y", 0))
175+
button = action_args.get("button", "left")
176+
if button in ("", "left"):
177+
return f"click({x}, {y})"
178+
return f"click({x}, {y}, {button})"
179+
if action_type == "double_click":
180+
return f"double_click({int(action_args.get('x', 0))}, {int(action_args.get('y', 0))})"
181+
if action_type == "type":
182+
text = str(action_args.get("text", ""))
183+
if len(text) > 60:
184+
text = f"{text[:57]}..."
185+
return f"type({text!r})"
186+
if action_type == "keypress":
187+
keys = action_args.get("keys", [])
188+
return f"keypress({keys})"
189+
if action_type == "scroll":
190+
return (
191+
f"scroll({int(action_args.get('x', 0))}, {int(action_args.get('y', 0))}, "
192+
f"dx={int(action_args.get('scroll_x', 0))}, dy={int(action_args.get('scroll_y', 0))})"
193+
)
194+
if action_type == "move":
195+
return f"move({int(action_args.get('x', 0))}, {int(action_args.get('y', 0))})"
196+
if action_type == "drag":
197+
return "drag(...)"
198+
if action_type == "wait":
199+
return f"wait({int(action_args.get('ms', 1000))}ms)"
200+
if action_type == "screenshot":
201+
return "screenshot()"
202+
return action_type
203+
204+
def _describe_batch_actions(self, actions: list[dict[str, Any]]) -> str:
205+
pieces: list[str] = []
206+
for action in actions:
207+
action_type = str(action.get("type", "unknown"))
208+
action_args = {k: v for k, v in action.items() if k != "type"}
209+
pieces.append(self._describe_action(action_type, action_args))
210+
return "batch[" + " -> ".join(pieces) + "]"
211+
129212
def _execute_computer_action(self, action_type, action_args):
130213
if action_type == "click":
131214
self.computer.click(**action_args)
@@ -150,14 +233,50 @@ def _execute_computer_action(self, action_type, action_args):
150233

151234
def handle_item(self, item):
152235
"""Handle each item; may cause a computer action + screenshot."""
236+
if item["type"] == "reasoning":
237+
text = self._extract_reasoning_text(item)
238+
if text:
239+
self._emit_event("reasoning_delta", {"text": text})
240+
153241
if item["type"] == "message":
154-
if self.print_steps:
155-
print(item["content"][0]["text"])
242+
if item.get("role") == "assistant":
243+
content = item.get("content", [])
244+
if isinstance(content, list):
245+
for part in content:
246+
if isinstance(part, dict) and isinstance(part.get("text"), str):
247+
self._emit_event("text_delta", {"text": part["text"]})
248+
self._emit_event("text_done", {})
156249

157250
if item["type"] == "function_call":
158251
name, args = item["name"], json.loads(item["arguments"])
159-
if self.print_steps:
160-
print(f"{name}({args})")
252+
elapsed_ms = self._current_model_elapsed_ms()
253+
if name == BATCH_FUNC_NAME:
254+
actions = args.get("actions", [])
255+
if isinstance(actions, list):
256+
typed_actions = [a for a in actions if isinstance(a, dict)]
257+
payload = {
258+
"action_type": "batch",
259+
"description": self._describe_batch_actions(typed_actions),
260+
"action": {"type": "batch", "actions": typed_actions},
261+
}
262+
if elapsed_ms is not None:
263+
payload["elapsed_ms"] = elapsed_ms
264+
self._emit_event(
265+
"action",
266+
payload,
267+
)
268+
else:
269+
payload = {
270+
"action_type": name,
271+
"description": f"{name}({json.dumps(args)})",
272+
"action": args,
273+
}
274+
if elapsed_ms is not None:
275+
payload["elapsed_ms"] = elapsed_ms
276+
self._emit_event(
277+
"action",
278+
payload,
279+
)
161280

162281
if name == BATCH_FUNC_NAME:
163282
return self._handle_batch_call(item["call_id"], args)
@@ -177,12 +296,26 @@ def handle_item(self, item):
177296
action = item["action"]
178297
action_type = action["type"]
179298
action_args = {k: v for k, v in action.items() if k != "type"}
180-
if self.print_steps:
181-
print(f"{action_type}({action_args})")
299+
elapsed_ms = self._current_model_elapsed_ms()
300+
payload = {
301+
"action_type": action_type,
302+
"description": self._describe_action(action_type, action_args),
303+
"action": action,
304+
}
305+
if elapsed_ms is not None:
306+
payload["elapsed_ms"] = elapsed_ms
307+
self._emit_event(
308+
"action",
309+
payload,
310+
)
182311

183312
self._execute_computer_action(action_type, action_args)
184313

185314
screenshot_base64 = self.computer.screenshot()
315+
self._emit_event(
316+
"screenshot",
317+
{"captured": True, "bytes_base64": len(screenshot_base64)},
318+
)
186319
if self.show_images:
187320
show_image(screenshot_base64)
188321

@@ -228,31 +361,55 @@ def _handle_batch_call(self, call_id, args):
228361
]
229362

230363
def run_full_turn(
231-
self, input_items, print_steps=True, debug=False, show_images=False
364+
self,
365+
input_items,
366+
print_steps=True,
367+
debug=False,
368+
show_images=False,
369+
on_event: Callable[[dict], None] | None = None,
232370
):
233371
self.print_steps = print_steps
234372
self.debug = debug
235373
self.show_images = show_images
374+
self.on_event = on_event
236375
new_items = []
376+
turns = 0
237377

238-
while new_items[-1].get("role") != "assistant" if new_items else True:
239-
self.debug_print([sanitize_message(msg) for msg in input_items + new_items])
378+
for message in input_items:
379+
if isinstance(message, dict):
380+
prompt = self._extract_prompt_text(message)
381+
if prompt:
382+
self._emit_event("prompt", {"text": prompt})
240383

241-
response = create_response(
242-
model=self.model,
243-
input=input_items + new_items,
244-
tools=self.tools,
245-
truncation="auto",
246-
instructions=BATCH_INSTRUCTIONS,
247-
)
248-
self.debug_print(response)
384+
try:
385+
while new_items[-1].get("role") != "assistant" if new_items else True:
386+
turns += 1
387+
self.debug_print([sanitize_message(msg) for msg in input_items + new_items])
388+
389+
self._model_request_started_at = time.time()
390+
response = create_response(
391+
model=self.model,
392+
input=input_items + new_items,
393+
tools=self.tools,
394+
truncation="auto",
395+
instructions=BATCH_INSTRUCTIONS,
396+
)
397+
self.debug_print(response)
398+
399+
if "output" not in response:
400+
if self.debug:
401+
print(response)
402+
raise ValueError("No output from model")
249403

250-
if "output" not in response and self.debug:
251-
print(response)
252-
raise ValueError("No output from model")
253-
else:
254404
new_items += response["output"]
255405
for item in response["output"]:
256406
new_items += self.handle_item(item)
407+
self._model_request_started_at = None
408+
self._emit_event("turn_done", {"turn": turns})
409+
except Exception as exc:
410+
self._model_request_started_at = None
411+
self._emit_event("error", {"message": str(exc)})
412+
raise
257413

414+
self._emit_event("run_complete", {"turns": turns})
258415
return new_items

0 commit comments

Comments
 (0)