agentenv-framework-protocol 0.1.381__tar.gz → 0.1.386__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.
Files changed (98) hide show
  1. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/PKG-INFO +50 -5
  2. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/README.md +47 -4
  3. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/pyproject.toml +5 -2
  4. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/_triggers.py +3 -0
  5. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/extensions.py +1 -0
  6. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/framework.py +1 -0
  7. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/__init__.py +7 -2
  8. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/card.py +6 -0
  9. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/checks.py +4 -0
  10. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/checks.py +116 -1
  11. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/fixture.py +1 -0
  12. agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/conformance/mp4.py +65 -0
  13. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/driver.py +4 -0
  14. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/extensions.py +16 -2
  15. agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/manifest.py +50 -0
  16. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/operations.py +3 -1
  17. agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/recording.py +502 -0
  18. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/runtime.py +7 -0
  19. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_agent.py +17 -0
  20. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_conformance.py +4 -2
  21. agentenv_framework_protocol-0.1.386/tests/test_computer_conformance_recording.py +208 -0
  22. agentenv_framework_protocol-0.1.386/tests/test_computer_recording.py +490 -0
  23. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/.gitignore +0 -0
  24. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/LICENSE +0 -0
  25. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/NOTICE +0 -0
  26. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/THIRD_PARTY_NOTICES.md +0 -0
  27. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/__init__.py +0 -0
  28. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/advanced_agent.py +0 -0
  29. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/basic_agent.py +0 -0
  30. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/custom_extensions_agent.py +0 -0
  31. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/multimodal_agent.py +0 -0
  32. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/reference_model.py +0 -0
  33. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/streaming_agent.py +0 -0
  34. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/__init__.py +0 -0
  35. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  36. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  37. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  38. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  39. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  40. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  41. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  42. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  43. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/agent_env_environment.py +0 -0
  44. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/client.py +0 -0
  45. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/_declare.py +0 -0
  46. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  47. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  48. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  49. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  50. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/dataplane.py +0 -0
  51. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/errors.py +0 -0
  52. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/frames.py +0 -0
  53. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/keys.py +0 -0
  54. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/linux.py +0 -0
  55. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/platform.py +0 -0
  56. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/queue.py +0 -0
  57. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/results.py +0 -0
  58. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/settings.py +0 -0
  59. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/spaces.py +0 -0
  60. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/tools.py +0 -0
  61. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/errors.py +0 -0
  62. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/manifest.py +0 -0
  63. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/preflight.py +0 -0
  64. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_access.py +0 -0
  65. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_client.py +0 -0
  66. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_context.py +0 -0
  67. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/transfers.py +0 -0
  68. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/types.py +0 -0
  69. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/computer_fake.py +0 -0
  70. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/probe_fake.py +0 -0
  71. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_agent_examples.py +0 -0
  72. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_object_transfers.py +0 -0
  73. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_peer_agents.py +0 -0
  74. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_proxy.py +0 -0
  75. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_staging.py +0 -0
  76. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_trajectory_log.py +0 -0
  77. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_agent_env_environment.py +0 -0
  78. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_client.py +0 -0
  79. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_conformance_checks.py +0 -0
  80. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_dataplane.py +0 -0
  81. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_declare.py +0 -0
  82. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_driver.py +0 -0
  83. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_extensions.py +0 -0
  84. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_frames.py +0 -0
  85. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_queue.py +0 -0
  86. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_spaces.py +0 -0
  87. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_data_objects.py +0 -0
  88. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_errors.py +0 -0
  89. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_extension_methods.py +0 -0
  90. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_intake.py +0 -0
  91. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_manifest.py +0 -0
  92. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_preflight.py +0 -0
  93. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_access.py +0 -0
  94. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_annotations.py +0 -0
  95. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_client.py +0 -0
  96. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_context.py +0 -0
  97. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_context_fastmcp.py +0 -0
  98. {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_context_injection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.381
3
+ Version: 0.1.386
4
4
  Summary: Open data-plane protocol and server SDK for agent environments.
5
5
  Project-URL: Homepage, https://www.agentenvframework.com
6
6
  Project-URL: Documentation, https://www.agentenvframework.com/docs
@@ -35,11 +35,13 @@ Requires-Dist: python-multipart>=0.0.13; extra == 'agent'
35
35
  Requires-Dist: regex>=2023.12.25; extra == 'agent'
36
36
  Requires-Dist: uvicorn>=0.30; extra == 'agent'
37
37
  Provides-Extra: computer
38
+ Requires-Dist: av>=17; extra == 'computer'
38
39
  Requires-Dist: mcp<2,>=1.25; extra == 'computer'
39
40
  Requires-Dist: pillow>=10.4; extra == 'computer'
40
41
  Requires-Dist: uvicorn>=0.30; extra == 'computer'
41
42
  Provides-Extra: dev
42
43
  Requires-Dist: a2a-sdk[http-server]<0.4,>=0.3.26; extra == 'dev'
44
+ Requires-Dist: av>=17; extra == 'dev'
43
45
  Requires-Dist: fastmcp<4,>=3.4; extra == 'dev'
44
46
  Requires-Dist: mcp<2,>=1.25; extra == 'dev'
45
47
  Requires-Dist: pillow>=10.4; extra == 'dev'
@@ -367,7 +369,7 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session:
367
369
 
368
370
  A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
369
371
  one as a `ComputerDriverEnvironment` subclass from `agentenv_protocol.computer`, which needs the
370
- `computer` extra (`pip install 'agentenv-framework-protocol[computer]'`: mcp, uvicorn and Pillow).
372
+ `computer` extra (`pip install 'agentenv-framework-protocol[computer]'`: mcp, uvicorn, Pillow and PyAV).
371
373
  The driver marks the methods that capture the screen and send input; the base class serves the
372
374
  computer contract from them, so the tools, results and errors can't drift from it. A complete
373
375
  driver for an X11 desktop is agent-env's reference driver, in its
@@ -418,6 +420,7 @@ pixels), at the display's size times its scale. Input operations may return an `
418
420
  | `@key_down`, `@key_up` | both or none | `computer-keyboard/v1` and its two tools |
419
421
  | `@run_in_session` | no; a Linux default | `computer-exec/v1` |
420
422
  | `@write_files` | no; a Linux default | `data/add` |
423
+ | `@record` | no | the stream `computer-recording/v1` records from; without it the base class polls `@capture` |
421
424
 
422
425
  The class is checked when it is defined: a missing required operation, half a group, two methods
423
426
  for one operation that some platform could both match, or features that don't match the
@@ -449,8 +452,9 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
449
452
  - **Framework extensions.** `computer-ready/v1` (`status`: real checks of the display and a
450
453
  capture, plus the driver's own `ready_checks()`; tools refuse `device_unavailable` until they
451
454
  pass once), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
452
- descriptions), `computer-keyboard/v1` and `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
453
- request_id}`: output capped per stream, a repeated `request_id` answered with the first result).
455
+ descriptions), `computer-keyboard/v1`, `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
456
+ request_id}`: output capped per stream, a repeated `request_id` answered with the first result) and,
457
+ with `recording`, `computer-recording/v1` (below).
454
458
  - **The data plane.** `data/add` takes file parts that name their path, relative to the session
455
459
  user's home, under the `data-objects/v1` marker (`{"path": ...}`), as inline bytes, an https URL,
456
460
  or a `transfers.ReadObject` read grant under the marker's `read`, and writes them atomically.
@@ -476,6 +480,47 @@ names the caller whose latest frame and others' input a call uses.
476
480
  `MyDriver.declaration(platform)` returns the card the class serves on a `Platform`, built without
477
481
  running the driver, its operations or its dependencies.
478
482
 
483
+ ### Recording the display
484
+
485
+ Every driver serves `computer-recording/v1`: video of the display, as MP4 (H.264) segments, for the
486
+ framework to start and end around the work it wants on record. `recording = Recording(...)` sets its
487
+ limits, and `recording = None` turns it off.
488
+
489
+ ```python
490
+ from agentenv_protocol.computer import RecordedFrame, Recording, record
491
+
492
+
493
+ class MyDriver(ComputerDriverEnvironment):
494
+ recording = Recording(max_fps=30) # max_concurrent=2, max_duration_s=86400, max_segment_s=15
495
+
496
+ @record
497
+ async def stream(self, hints): # hints.scope, fps, max_edge, cursor, max_segment_s, directory
498
+ async for image, captured_at in my_screen_stream(hints.fps):
499
+ yield RecordedFrame(image, captured_at) # a Pillow image or a PyAV VideoFrame
500
+ ```
501
+
502
+ - **The stream.** `@record` yields `RecordedFrame`s from a stream the OS pushes, which the base class
503
+ scales, encodes and splits into segments, each frame timed by its `captured_at`; or
504
+ `RecordedSegment`s, files a native recorder finished under `hints.directory`, which it only hashes,
505
+ uploads and lists. The base class closes the stream at `end`. A source that blocks, as most OS
506
+ capture APIs do, goes through `frames_in_thread(frames)`: the generator `frames()` runs in a thread
507
+ of its own, the latest 8 frames wait for the encoder, and it is closed when the recording stops. A
508
+ driver without `@record` records by the base class polling `@capture`, with a pointer drawn at
509
+ `@pointer_position`: the slow path, at most 15 fps.
510
+ - **The methods.** `start {request_id, scope?, fps?, max_edge?, cursor?, upload?}` returns
511
+ `{recording_id, started_at, format}`; `end {recording_id, upload?, reason?}` closes the last segment
512
+ and returns the manifest. A repeated `request_id`, or a repeated `end`, answers the same. A start
513
+ past `max_concurrent` is refused `limit_exceeded`; `max_duration_s`, free disk under 512 MB or a stream
514
+ failing for 60 s end a recording by itself.
515
+ - **Segments.** A segment closes at `max_segment_s`, when the frame size changes or after a gap in the
516
+ stream. Each uploads as it closes under `start`'s `write_namespace` grant, at
517
+ `<recording_id>/<index>.mp4`, and is then deleted; `end`'s own grant uploads any that missed. With no
518
+ grant they stay under `~/.agentenv-recordings/`.
519
+ - **The manifest** (`RecordingManifest`). Its `segments` (`ManifestSegment`) carry each one's path,
520
+ `started_at` and `ended_at` (its first and last frames' capture times), size, frame count, byte size,
521
+ SHA-256 and whether it uploaded, and `ended_by` says why the recording ended: `end`, `reset`,
522
+ `teardown`, `limit` or `error`. A field it doesn't know is left out.
523
+
479
524
  ### Checking a driver: the conformance probe
480
525
 
481
526
  The probe checks a running driver against the contract through its card, tools and extensions. It
@@ -498,7 +543,7 @@ The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach t
498
543
  | Level | Checks | Needs on the computer |
499
544
  |---|---|---|
500
545
  | `basic` | P0 ready and a screenshot; P1 the card and tool schemas; P2 native frames, regions and JPEG; P5 refusals and their codes; X the declared extensions (`computer-display`, `computer-spaces`, `computer-keyboard`, `computer-exec`, `data/get`, per-role tool access, the pointer) | nothing |
501
- | `full` | `basic`, then P3 colours land where the mapping says; P4 clicks show their effect; P5 refusals change nothing; P6 typed and pressed keys arrive; P7 scrolls and drags arrive; P9 commands and `data/add` run as one unprivileged user; X pointer and keyboard input, `data/add` | Linux, `python3` with Tk, a 1280x800 screen or larger, `computer-exec` and `data/add` |
546
+ | `full` | `basic`, then P3 colours land where the mapping says; P4 clicks show their effect; P5 refusals change nothing; P6 typed and pressed keys arrive; P7 scrolls and drags arrive; P9 commands and `data/add` run as one unprivileged user; X pointer and keyboard input, `data/add`, and a recording's segments matching their files with no gap, a repeated `request_id` joining it and `max_concurrent` held | Linux, `python3` with Tk, a 1280x800 screen or larger, `computer-exec` and `data/add` |
502
547
 
503
548
  At `full` the probe writes the Fixture Desk, a Tk window (`conformance/fixture.py`), into
504
549
  `~/.agentenv-probe` with `data/add` and starts it with `computer-exec`. It aims at the boxes the
@@ -316,7 +316,7 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session:
316
316
 
317
317
  A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
318
318
  one as a `ComputerDriverEnvironment` subclass from `agentenv_protocol.computer`, which needs the
319
- `computer` extra (`pip install 'agentenv-framework-protocol[computer]'`: mcp, uvicorn and Pillow).
319
+ `computer` extra (`pip install 'agentenv-framework-protocol[computer]'`: mcp, uvicorn, Pillow and PyAV).
320
320
  The driver marks the methods that capture the screen and send input; the base class serves the
321
321
  computer contract from them, so the tools, results and errors can't drift from it. A complete
322
322
  driver for an X11 desktop is agent-env's reference driver, in its
@@ -367,6 +367,7 @@ pixels), at the display's size times its scale. Input operations may return an `
367
367
  | `@key_down`, `@key_up` | both or none | `computer-keyboard/v1` and its two tools |
368
368
  | `@run_in_session` | no; a Linux default | `computer-exec/v1` |
369
369
  | `@write_files` | no; a Linux default | `data/add` |
370
+ | `@record` | no | the stream `computer-recording/v1` records from; without it the base class polls `@capture` |
370
371
 
371
372
  The class is checked when it is defined: a missing required operation, half a group, two methods
372
373
  for one operation that some platform could both match, or features that don't match the
@@ -398,8 +399,9 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
398
399
  - **Framework extensions.** `computer-ready/v1` (`status`: real checks of the display and a
399
400
  capture, plus the driver's own `ready_checks()`; tools refuse `device_unavailable` until they
400
401
  pass once), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
401
- descriptions), `computer-keyboard/v1` and `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
402
- request_id}`: output capped per stream, a repeated `request_id` answered with the first result).
402
+ descriptions), `computer-keyboard/v1`, `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
403
+ request_id}`: output capped per stream, a repeated `request_id` answered with the first result) and,
404
+ with `recording`, `computer-recording/v1` (below).
403
405
  - **The data plane.** `data/add` takes file parts that name their path, relative to the session
404
406
  user's home, under the `data-objects/v1` marker (`{"path": ...}`), as inline bytes, an https URL,
405
407
  or a `transfers.ReadObject` read grant under the marker's `read`, and writes them atomically.
@@ -425,6 +427,47 @@ names the caller whose latest frame and others' input a call uses.
425
427
  `MyDriver.declaration(platform)` returns the card the class serves on a `Platform`, built without
426
428
  running the driver, its operations or its dependencies.
427
429
 
430
+ ### Recording the display
431
+
432
+ Every driver serves `computer-recording/v1`: video of the display, as MP4 (H.264) segments, for the
433
+ framework to start and end around the work it wants on record. `recording = Recording(...)` sets its
434
+ limits, and `recording = None` turns it off.
435
+
436
+ ```python
437
+ from agentenv_protocol.computer import RecordedFrame, Recording, record
438
+
439
+
440
+ class MyDriver(ComputerDriverEnvironment):
441
+ recording = Recording(max_fps=30) # max_concurrent=2, max_duration_s=86400, max_segment_s=15
442
+
443
+ @record
444
+ async def stream(self, hints): # hints.scope, fps, max_edge, cursor, max_segment_s, directory
445
+ async for image, captured_at in my_screen_stream(hints.fps):
446
+ yield RecordedFrame(image, captured_at) # a Pillow image or a PyAV VideoFrame
447
+ ```
448
+
449
+ - **The stream.** `@record` yields `RecordedFrame`s from a stream the OS pushes, which the base class
450
+ scales, encodes and splits into segments, each frame timed by its `captured_at`; or
451
+ `RecordedSegment`s, files a native recorder finished under `hints.directory`, which it only hashes,
452
+ uploads and lists. The base class closes the stream at `end`. A source that blocks, as most OS
453
+ capture APIs do, goes through `frames_in_thread(frames)`: the generator `frames()` runs in a thread
454
+ of its own, the latest 8 frames wait for the encoder, and it is closed when the recording stops. A
455
+ driver without `@record` records by the base class polling `@capture`, with a pointer drawn at
456
+ `@pointer_position`: the slow path, at most 15 fps.
457
+ - **The methods.** `start {request_id, scope?, fps?, max_edge?, cursor?, upload?}` returns
458
+ `{recording_id, started_at, format}`; `end {recording_id, upload?, reason?}` closes the last segment
459
+ and returns the manifest. A repeated `request_id`, or a repeated `end`, answers the same. A start
460
+ past `max_concurrent` is refused `limit_exceeded`; `max_duration_s`, free disk under 512 MB or a stream
461
+ failing for 60 s end a recording by itself.
462
+ - **Segments.** A segment closes at `max_segment_s`, when the frame size changes or after a gap in the
463
+ stream. Each uploads as it closes under `start`'s `write_namespace` grant, at
464
+ `<recording_id>/<index>.mp4`, and is then deleted; `end`'s own grant uploads any that missed. With no
465
+ grant they stay under `~/.agentenv-recordings/`.
466
+ - **The manifest** (`RecordingManifest`). Its `segments` (`ManifestSegment`) carry each one's path,
467
+ `started_at` and `ended_at` (its first and last frames' capture times), size, frame count, byte size,
468
+ SHA-256 and whether it uploaded, and `ended_by` says why the recording ended: `end`, `reset`,
469
+ `teardown`, `limit` or `error`. A field it doesn't know is left out.
470
+
428
471
  ### Checking a driver: the conformance probe
429
472
 
430
473
  The probe checks a running driver against the contract through its card, tools and extensions. It
@@ -447,7 +490,7 @@ The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach t
447
490
  | Level | Checks | Needs on the computer |
448
491
  |---|---|---|
449
492
  | `basic` | P0 ready and a screenshot; P1 the card and tool schemas; P2 native frames, regions and JPEG; P5 refusals and their codes; X the declared extensions (`computer-display`, `computer-spaces`, `computer-keyboard`, `computer-exec`, `data/get`, per-role tool access, the pointer) | nothing |
450
- | `full` | `basic`, then P3 colours land where the mapping says; P4 clicks show their effect; P5 refusals change nothing; P6 typed and pressed keys arrive; P7 scrolls and drags arrive; P9 commands and `data/add` run as one unprivileged user; X pointer and keyboard input, `data/add` | Linux, `python3` with Tk, a 1280x800 screen or larger, `computer-exec` and `data/add` |
493
+ | `full` | `basic`, then P3 colours land where the mapping says; P4 clicks show their effect; P5 refusals change nothing; P6 typed and pressed keys arrive; P7 scrolls and drags arrive; P9 commands and `data/add` run as one unprivileged user; X pointer and keyboard input, `data/add`, and a recording's segments matching their files with no gap, a repeated `request_id` joining it and `max_concurrent` held | Linux, `python3` with Tk, a 1280x800 screen or larger, `computer-exec` and `data/add` |
451
494
 
452
495
  At `full` the probe writes the Fixture Desk, a Tk window (`conformance/fixture.py`), into
453
496
  `~/.agentenv-probe` with `data/add` and starts it with `computer-exec`. It aims at the boxes the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.381"
3
+ version = "0.1.386"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -35,8 +35,10 @@ agent = [
35
35
  "regex>=2023.12.25",
36
36
  "uvicorn>=0.30",
37
37
  ]
38
- # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP and crops and encodes frames.
38
+ # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, and
39
+ # records the display as H.264 with PyAV (computer-recording/v1).
39
40
  computer = [
41
+ "av>=17",
40
42
  "mcp>=1.25,<2",
41
43
  "Pillow>=10.4",
42
44
  "uvicorn>=0.30",
@@ -50,6 +52,7 @@ dev = [
50
52
  "mcp>=1.25,<2",
51
53
  "fastmcp>=3.4,<4", # the standalone-fastmcp mount path
52
54
  "Pillow>=10.4", # the computer extra's frame crops and encodings
55
+ "av>=17", # the computer extra's encoder, and the tests' decoder
53
56
  ]
54
57
  [project.urls]
55
58
  Homepage = "https://www.agentenvframework.com"
@@ -206,6 +206,7 @@ class TriggerEngine:
206
206
  solver_message: str = "",
207
207
  context_id: str = "default",
208
208
  env_triggers: Any = None,
209
+ record_unfired: bool = True,
209
210
  ) -> dict[str, Any]:
210
211
  if isinstance(turn, bool) or not isinstance(turn, int) or turn < 1:
211
212
  raise TriggerError("decide: 'turn' must be a positive integer")
@@ -256,6 +257,8 @@ class TriggerEngine:
256
257
  elif action["type"] == "end":
257
258
  done = True
258
259
 
260
+ if not fired_ids and not record_unfired:
261
+ return {"parts": [], "done": False, "fired": []}
259
262
  # Commit only after every predicate succeeds. A timed-out decision can then
260
263
  # be retried without consuming the turn or partially firing once-only rules.
261
264
  context = self._context_state(context_id)
@@ -285,6 +285,7 @@ class TriggerDecideRequest(ExtensionRequest):
285
285
  solver_message: str = Field(default="", max_length=MAX_SOLVER_MESSAGE_LENGTH)
286
286
  context_id: str = "default"
287
287
  env_triggers: dict[str, dict[str, Any]] | None = None
288
+ record_unfired: bool = True # False: a decision where nothing fires changes no state, so it can be repeated
288
289
 
289
290
 
290
291
  PROXY_TOKEN_MIN_LENGTH = 22
@@ -836,6 +836,7 @@ class _SdkServices:
836
836
  solver_message=request.solver_message,
837
837
  context_id=request.context_id,
838
838
  env_triggers=request.env_triggers,
839
+ record_unfired=request.record_unfired,
839
840
  )
840
841
 
841
842
  async def triggers_state(self) -> dict[str, Any]:
@@ -25,6 +25,7 @@ from .card import (
25
25
  COMPUTER_KEYBOARD,
26
26
  COMPUTER_POINTER,
27
27
  COMPUTER_READY,
28
+ COMPUTER_RECORDING,
28
29
  COMPUTER_SPACES,
29
30
  )
30
31
  from .operations import (
@@ -44,13 +45,16 @@ from .operations import (
44
45
  pointer_position,
45
46
  pointer_up,
46
47
  press,
48
+ record,
47
49
  run_in_session,
48
50
  scroll,
49
51
  set_display,
50
52
  type_text,
51
53
  write_files,
52
54
  )
55
+ from .manifest import ManifestSegment, RecordingManifest
53
56
  from .platform import Platform
57
+ from .recording import RecordedFrame, RecordedSegment, RecordHints, Recording, frames_in_thread
54
58
  from .settings import SETTINGS_ENV, ComputerSettings
55
59
 
56
60
  __all__ = [
@@ -58,10 +62,11 @@ __all__ = [
58
62
  "Display", "Image", "Effect", "ExecResult", "ReadyCheck", "Platform", "ComputerSettings", "SETTINGS_ENV",
59
63
  "display", "set_display", "capture", "click", "drag", "scroll", "type_text", "press",
60
64
  "pointer_move", "pointer_down", "pointer_up", "pointer_position", "key_down", "key_up",
61
- "run_in_session", "write_files",
65
+ "run_in_session", "write_files", "record", "Recording", "RecordHints", "RecordedFrame", "RecordedSegment",
66
+ "frames_in_thread", "RecordingManifest", "ManifestSegment",
62
67
  "ComputerError", "InvalidArgument", "OutOfBounds", "StaleFrame", "InputBusy", "PermissionDenied", "Unsupported",
63
68
  "BackgroundUnavailable", "TargetUnavailable", "DeviceUnavailable", "LimitExceeded", "Timeout", "DeliveryFailed",
64
69
  "Internal",
65
70
  "COMPUTER", "COMPUTER_READY", "COMPUTER_DISPLAY", "COMPUTER_SPACES", "COMPUTER_POINTER", "COMPUTER_KEYBOARD",
66
- "COMPUTER_EXEC",
71
+ "COMPUTER_EXEC", "COMPUTER_RECORDING",
67
72
  ]
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ from .recording import recording_params
8
9
  from ..types import METHOD_ADD, EnvironmentExtension, EnvironmentTool, IntakeDeclaration, IntakeFormat, intake_extension
9
10
 
10
11
  if TYPE_CHECKING:
@@ -17,6 +18,7 @@ COMPUTER_SPACES = "urn:agentenv:computer-spaces/v1"
17
18
  COMPUTER_POINTER = "urn:agentenv:computer-pointer/v1"
18
19
  COMPUTER_KEYBOARD = "urn:agentenv:computer-keyboard/v1"
19
20
  COMPUTER_EXEC = "urn:agentenv:computer-exec/v1"
21
+ COMPUTER_RECORDING = "urn:agentenv:computer-recording/v1"
20
22
 
21
23
  MAX_EXEC_TIMEOUT_S = 3600
22
24
  KEYBOARD_RANGES = {"tap_hold_ms": (0, 1000), "type_delay_ms": (0, 1000), "max_hold_ms": (0, 600_000)}
@@ -48,6 +50,8 @@ def served_extensions(driver: ComputerDriverEnvironment,
48
50
  need = _EXTENSION_NEEDS.get(extension.uri)
49
51
  if need is not None and not driver.has(need):
50
52
  continue
53
+ if extension.uri == COMPUTER_RECORDING and driver.recording is None:
54
+ continue
51
55
  dropped = {op for (uri, op), need in _METHOD_NEEDS.items() if uri == extension.uri and not driver.has(need)}
52
56
  if dropped:
53
57
  methods = {op: entry for op, entry in extension.params["methods"].items() if op not in dropped}
@@ -70,6 +74,8 @@ def extension_params(driver: ComputerDriverEnvironment, uri: str) -> dict:
70
74
  "layouts": list(driver.layouts)}
71
75
  if uri == COMPUTER_EXEC:
72
76
  return {"max_output_bytes": driver.settings.exec_output_bytes, "max_timeout_s": MAX_EXEC_TIMEOUT_S}
77
+ if uri == COMPUTER_RECORDING and driver.recording is not None:
78
+ return recording_params(driver.recording, driver.has("record"))
73
79
  return {}
74
80
 
75
81
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from ..errors import AgentEnvError
6
6
  from .keys import canonical
7
7
  from .operations import declared
8
+ from .recording import check_recording
8
9
 
9
10
  BUTTONS = ("left", "right", "middle", "back", "forward")
10
11
 
@@ -38,5 +39,8 @@ def check_features(cls: type) -> None:
38
39
  problems.append(f"keys: {e.message}")
39
40
  if ("set_display" in declared(cls)) != bool(cls.display_modes):
40
41
  problems.append("display_modes and @set_display come together")
42
+ problems += check_recording(cls.recording)
43
+ if "record" in declared(cls) and cls.recording is None:
44
+ problems.append("@record needs recording, which recording = None turns off")
41
45
  if problems:
42
46
  raise TypeError(f"{cls.__qualname__}: " + "; ".join(problems))
@@ -4,15 +4,19 @@ fixture with data/add, launches it with computer-exec and reads its state back t
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
+ import base64
8
+ import contextlib
7
9
  import json
8
10
  import math
9
11
  import time
10
12
  import uuid
11
13
  from collections.abc import Awaitable, Callable
14
+ from datetime import datetime
12
15
  from importlib import resources
13
16
 
14
17
  from mcp import ClientSession
15
18
  from mcp.types import CallToolResult
19
+ from pydantic import ValidationError
16
20
 
17
21
  from ... import client as protocol
18
22
  from ...client import ExtensionCallError
@@ -25,11 +29,15 @@ from ..card import (
25
29
  COMPUTER_KEYBOARD,
26
30
  COMPUTER_POINTER,
27
31
  COMPUTER_READY,
32
+ COMPUTER_RECORDING,
28
33
  COMPUTER_SPACES,
29
34
  GROUP_TOOLS,
30
35
  POINT_TOOLS,
31
36
  )
37
+ from ..manifest import RecordingManifest
38
+ from ..recording import RECORDINGS_DIR
32
39
  from .client import Driver, image
40
+ from .mp4 import read_mp4
33
41
  from .report import CheckResult
34
42
 
35
43
  MINIMUM_TOOLS = ("computer_screenshot", "computer_click", "computer_drag", "computer_scroll", "computer_type",
@@ -45,6 +53,16 @@ POLL_S = 0.15
45
53
  #: How long the fixture may take to start.
46
54
  FIXTURE_START_S = 30.0
47
55
  NORM = 1000
56
+ #: How long the probe records, and the frame rate it asks for.
57
+ RECORD_S = 2.0
58
+ RECORD_FPS = 5
59
+ #: Prints a file's size and SHA-256 as JSON, run with ``python3 -c`` on the computer.
60
+ READ_DIGEST = ("import hashlib,json,sys;d=open(sys.argv[1],'rb').read();"
61
+ "print(json.dumps({'size':len(d),'sha256':hashlib.sha256(d).hexdigest()}))")
62
+ #: Prints ``length`` bytes of a file from ``offset``, base64, under computer-exec's output cap.
63
+ READ_CHUNK = ("import base64,sys;f=open(sys.argv[1],'rb');f.seek(int(sys.argv[2]));"
64
+ "print(base64.b64encode(f.read(int(sys.argv[3]))).decode())")
65
+ CHUNK_BYTES = 512 << 10
48
66
  #: The checks only ``full`` runs, recorded unverified when the fixture can't be staged.
49
67
  FULL_CHECKS = (("P3", "fixture colours land where the mapping says"), ("P4", "clicks show their effect"),
50
68
  ("P5", "refusals leave the fixture as it was"), ("P6", "typed and pressed keys arrive"),
@@ -89,7 +107,7 @@ class Probe:
89
107
  return self._skip_full(f"the fixture didn't start: {problem}")
90
108
  for check, section in (("P9", self._security), ("P3", self._geometry), ("P4", self._effects),
91
109
  ("P5", self._unchanged), ("P6", self._typing), ("P7", self._scroll_and_drag),
92
- ("X", self._pointer_input), ("X", self._data_add)):
110
+ ("X", self._pointer_input), ("X", self._data_add), ("X", self._recording)):
93
111
  await self._guarded(check, section)
94
112
  finally:
95
113
  await self._unstage()
@@ -721,6 +739,103 @@ class Probe:
721
739
 
722
740
  await self.check("X", "a file data/add writes is there for an app to read", picked_up, *DATA_PLANE)
723
741
 
742
+ async def _recording(self) -> None:
743
+ if not self.driver.declares(COMPUTER_RECORDING):
744
+ return
745
+ params = self.driver.params(COMPUTER_RECORDING)
746
+ fps = min(RECORD_FPS, (params.get("fps") or {}).get("max") or RECORD_FPS)
747
+ made: list[str] = []
748
+ state: dict = {}
749
+
750
+ async def start(request_id: str) -> dict:
751
+ started = await self.driver.invoke(COMPUTER_RECORDING, "start", {"request_id": request_id, "fps": fps})
752
+ if started["recording_id"] not in made:
753
+ made.append(started["recording_id"])
754
+ return started
755
+
756
+ async def recorded() -> str | None:
757
+ request_id = f"{PROBE_ROLE}-recording-{uuid.uuid4().hex[:8]}"
758
+ started = await start(request_id)
759
+ state["joined"] = await start(request_id) == started
760
+ await asyncio.sleep(RECORD_S)
761
+ manifest = await self.driver.invoke(COMPUTER_RECORDING, "end", {"recording_id": started["recording_id"]})
762
+ return await self._segments_match(manifest, fps)
763
+
764
+ async def joined() -> str | None:
765
+ return None if state.get("joined") else "a repeated request_id started a second recording"
766
+
767
+ async def limited() -> str | None:
768
+ most = params.get("max_concurrent")
769
+ if not isinstance(most, int):
770
+ return "the card declares no max_concurrent"
771
+ for _ in range(most):
772
+ await start(f"{PROBE_ROLE}-limit-{uuid.uuid4().hex[:8]}")
773
+ try:
774
+ extra = await start(f"{PROBE_ROLE}-limit-{uuid.uuid4().hex[:8]}")
775
+ except ExtensionCallError as e:
776
+ return None if e.code == "limit_exceeded" else f"refused {e.code!r}, not 'limit_exceeded': {e.detail}"
777
+ return f"recording {most + 1} of at most {most} started: {extra['recording_id']}"
778
+
779
+ try:
780
+ await self.check("X", "a recording's segments match their files, with no gap between them", recorded,
781
+ COMPUTER_RECORDING)
782
+ await self.check("X", "a repeated request_id joins the first recording", joined, COMPUTER_RECORDING)
783
+ await self.check("X", "a recording past max_concurrent is refused", limited, COMPUTER_RECORDING)
784
+ finally:
785
+ for recording_id in made:
786
+ with contextlib.suppress(Exception):
787
+ await self.driver.invoke(COMPUTER_RECORDING, "end", {"recording_id": recording_id})
788
+ await self.driver.run("rm", "-rf", f"{RECORDINGS_DIR}/{recording_id}")
789
+ await self.driver.run("sh", "-c", f"rmdir {RECORDINGS_DIR} 2>/dev/null || true")
790
+
791
+ async def _segments_match(self, wire: dict, fps: int) -> str | None:
792
+ """What is wrong with a recording without a grant, read back from the computer, or None."""
793
+ try:
794
+ manifest = RecordingManifest.model_validate(wire)
795
+ except ValidationError as e:
796
+ return f"its manifest isn't a computer-recording/v1 manifest: {e}"
797
+ segments = manifest.segments
798
+ if manifest.ended_by != "end" or not segments:
799
+ return f"it ended by {manifest.ended_by!r} with {len(segments)} segments"
800
+ if unmeasured := [s.index for s in segments if not s.measured]:
801
+ return f"segments {unmeasured} lack their times, size, frame count or digest"
802
+ span = (_when(segments[-1].ended_at) - _when(segments[0].started_at)).total_seconds()
803
+ frames = sum(s.frames for s in segments)
804
+ if span < RECORD_S - 2 / fps or frames < fps * RECORD_S / 2:
805
+ return f"{frames} frames over {span:.2f} s of a {RECORD_S:g} s recording at {fps} fps"
806
+ for before, after in zip(segments, segments[1:]):
807
+ gap = (_when(after.started_at) - _when(before.ended_at)).total_seconds()
808
+ if gap > 2 / fps:
809
+ return f"{gap:.2f} s between segments {before.index} and {after.index}"
810
+ for segment in segments:
811
+ path = f"{RECORDINGS_DIR}/{segment.path}"
812
+ out = await self.driver.run("python3", "-c", READ_DIGEST, path)
813
+ if out.get("exit_code") != 0:
814
+ return f"segment {segment.index} couldn't be read back: {out.get('stderr', '')[-200:]}"
815
+ file = json.loads(out["stdout"])
816
+ if (file["size"], file["sha256"]) != (segment.bytes, segment.sha256):
817
+ return f"segment {segment.index}'s file is {file['size']} bytes, sha256 {file['sha256'][:12]}; " \
818
+ f"the manifest says {segment.bytes}, {segment.sha256[:12]}"
819
+ data = b""
820
+ while len(data) < file["size"]:
821
+ chunk = await self.driver.run("python3", "-c", READ_CHUNK, path, str(len(data)), str(CHUNK_BYTES))
822
+ read = base64.b64decode(chunk["stdout"]) if chunk.get("exit_code") == 0 else b""
823
+ if not read:
824
+ return f"segment {segment.index} stopped reading back at byte {len(data)} of {file['size']}: " \
825
+ f"{chunk.get('stderr', '')[-200:]}"
826
+ data += read
827
+ mp4 = read_mp4(data)
828
+ span = (_when(segment.ended_at) - _when(segment.started_at)).total_seconds()
829
+ if ((mp4["width"], mp4["height"], mp4["frames"]) != (segment.width, segment.height, segment.frames)
830
+ or abs(mp4["duration_s"] - span) > 2 / fps):
831
+ return f"segment {segment.index}'s MP4 is {mp4}; the manifest says {segment.width}x" \
832
+ f"{segment.height}, {segment.frames} frames over {span:.2f} s"
833
+ return None
834
+
835
+
836
+ def _when(iso: str) -> datetime:
837
+ return datetime.fromisoformat(iso.replace("Z", "+00:00"))
838
+
724
839
 
725
840
  def _rgb(colour: str) -> tuple[int, int, int]:
726
841
  return int(colour[1:3], 16), int(colour[3:5], 16), int(colour[5:7], 16)
@@ -27,6 +27,7 @@ def main(state_dir: Path) -> None:
27
27
  root.title("Fixture Desk")
28
28
  width, height = root.winfo_screenwidth(), root.winfo_screenheight()
29
29
  root.geometry(f"{width}x{height}+0+0")
30
+ root.attributes("-fullscreen", True)
30
31
  root.configure(bg="#f0f0f0")
31
32
  state: dict = {"name": "", "notes": "", "keys": [], "saved_count": 0, "subscribed": False, "selected": None,
32
33
  "scroll_top": 0.0, "cells": {}, "right_clicks": 0, "double_clicks": 0, "drags": [],
@@ -0,0 +1,65 @@
1
+ """What the probe reads from an MP4 without decoding it: the video track's size, frame count and duration, from the
2
+ ``moov`` box's ``tkhd``, ``mdhd`` and ``stts``."""
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from collections.abc import Iterator
7
+
8
+
9
+ def read_mp4(data: bytes) -> dict:
10
+ """``{"width", "height", "frames", "duration_s"}`` of the first video track. Raises ValueError for a file that
11
+ isn't an MP4 with one."""
12
+ top = dict(_boxes(data, 0, len(data)))
13
+ if b"ftyp" not in top or b"moov" not in top:
14
+ raise ValueError(f"not an MP4: its top-level boxes are {sorted(t.decode(errors='replace') for t in top)}")
15
+ for trak in _children(data, top[b"moov"], b"trak"):
16
+ mdia = _child(data, trak, b"mdia")
17
+ hdlr = _child(data, mdia, b"hdlr")
18
+ if data[hdlr[0] + 8:hdlr[0] + 12] != b"vide":
19
+ continue
20
+ width, height = _tkhd_size(data, _child(data, trak, b"tkhd"))
21
+ timescale, duration = _mdhd(data, _child(data, mdia, b"mdhd"))
22
+ stts = _child(data, _child(data, _child(data, mdia, b"minf"), b"stbl"), b"stts")
23
+ entries = struct.unpack(">I", data[stts[0] + 4:stts[0] + 8])[0]
24
+ frames = sum(struct.unpack(">I", data[stts[0] + 8 + 8 * i:stts[0] + 12 + 8 * i])[0] for i in range(entries))
25
+ return {"width": width, "height": height, "frames": frames, "duration_s": duration / timescale}
26
+ raise ValueError("the MP4 has no video track")
27
+
28
+
29
+ def _boxes(data: bytes, start: int, end: int) -> Iterator[tuple[bytes, tuple[int, int]]]:
30
+ """Each box between ``start`` and ``end``: its type, and where its payload starts and ends."""
31
+ at = start
32
+ while at + 8 <= end:
33
+ size, kind = struct.unpack(">I4s", data[at:at + 8])
34
+ header = 8
35
+ if size == 1:
36
+ size, header = struct.unpack(">Q", data[at + 8:at + 16])[0], 16
37
+ elif size == 0:
38
+ size = end - at
39
+ if size < header or at + size > end:
40
+ raise ValueError(f"a {kind.decode(errors='replace')} box runs past its container")
41
+ yield kind, (at + header, at + size)
42
+ at += size
43
+
44
+
45
+ def _children(data: bytes, box: tuple[int, int], kind: bytes) -> list[tuple[int, int]]:
46
+ return [span for k, span in _boxes(data, *box) if k == kind]
47
+
48
+
49
+ def _child(data: bytes, box: tuple[int, int], kind: bytes) -> tuple[int, int]:
50
+ found = _children(data, box, kind)
51
+ if not found:
52
+ raise ValueError(f"no {kind.decode()} box where the MP4 needs one")
53
+ return found[0]
54
+
55
+
56
+ def _tkhd_size(data: bytes, box: tuple[int, int]) -> tuple[int, int]:
57
+ at = box[0] + (88 if data[box[0]] == 1 else 76)
58
+ width, height = struct.unpack(">II", data[at:at + 8])
59
+ return width >> 16, height >> 16
60
+
61
+
62
+ def _mdhd(data: bytes, box: tuple[int, int]) -> tuple[int, int]:
63
+ if data[box[0]] == 1:
64
+ return struct.unpack(">IQ", data[box[0] + 20:box[0] + 32])
65
+ return struct.unpack(">II", data[box[0] + 12:box[0] + 20])
@@ -14,6 +14,7 @@ from .dataplane import DataPlane
14
14
  from .extensions import Extensions
15
15
  from .operations import ReadyCheck, check_class, default_for
16
16
  from .platform import Platform, detect
17
+ from .recording import Recording
17
18
  from .runtime import ComputerRuntime
18
19
  from .settings import ComputerSettings, load_settings
19
20
  from .tools import Tools
@@ -42,6 +43,9 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
42
43
  layouts: ClassVar[tuple[str, ...]] = ("us",)
43
44
  #: The platforms the driver supports, as the driver env declares them: ``[{"os": {...}, "arch": [...]}]``.
44
45
  supports: ClassVar[list] = []
46
+ #: computer-recording/v1, from ``@record`` if the driver has one, else by polling ``@capture``; ``None`` turns
47
+ #: it off.
48
+ recording: ClassVar[Recording | None] = Recording()
45
49
  tool_access = True
46
50
  #: What the base class keeps for the driver, built by ``__init__``.
47
51
  _computer: ComputerRuntime | None = None
@@ -1,5 +1,5 @@
1
- """The framework extensions agentenv calls, never agents: readiness, display, coordinate spaces, keyboard settings
2
- and commands in the session."""
1
+ """The framework extensions agentenv calls, never agents: readiness, display, coordinate spaces, keyboard settings,
2
+ commands in the session and recordings."""
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, Callable, Optional
@@ -11,6 +11,7 @@ from .card import (
11
11
  COMPUTER_EXEC,
12
12
  COMPUTER_KEYBOARD,
13
13
  COMPUTER_READY,
14
+ COMPUTER_RECORDING,
14
15
  COMPUTER_SPACES,
15
16
  KEYBOARD_RANGES,
16
17
  MAX_EXEC_TIMEOUT_S,
@@ -111,6 +112,19 @@ class Extensions:
111
112
  cap = self.settings.exec_output_bytes
112
113
  return await self._computer.command(request_id, lambda: _run(self._computer, cap, argv, cwd, env, timeout))
113
114
 
115
+ @extension(COMPUTER_RECORDING, op="start",
116
+ description="Record the display: start returns a recording whose segments upload through its grant as "
117
+ "they close, and end seals it and returns its manifest; repeats answer the same.")
118
+ async def _computer_recording_start(self, request_id: str, scope: str = "display", window: Optional[str] = None,
119
+ fps: Optional[int] = None, max_edge: Optional[int] = None, cursor: bool = True,
120
+ upload: Optional[dict] = None) -> dict:
121
+ return await self._computer.recordings.start(request_id, scope, window, fps, max_edge, cursor, upload)
122
+
123
+ @extension(COMPUTER_RECORDING, op="end")
124
+ async def _computer_recording_end(self, recording_id: str, upload: Optional[dict] = None,
125
+ reason: str = "end") -> dict:
126
+ return await self._computer.recordings.end(recording_id, upload, reason)
127
+
114
128
 
115
129
  async def _run(computer: ComputerRuntime, cap: int, argv: list, cwd: Optional[str], env: Optional[dict],
116
130
  timeout: float) -> dict: