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.
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/PKG-INFO +50 -5
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/README.md +47 -4
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/pyproject.toml +5 -2
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/_triggers.py +3 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/extensions.py +1 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/framework.py +1 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/__init__.py +7 -2
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/card.py +6 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/checks.py +4 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/checks.py +116 -1
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/fixture.py +1 -0
- agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/conformance/mp4.py +65 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/driver.py +4 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/extensions.py +16 -2
- agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/manifest.py +50 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/operations.py +3 -1
- agentenv_framework_protocol-0.1.386/src/agentenv_protocol/computer/recording.py +502 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/runtime.py +7 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_agent.py +17 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_conformance.py +4 -2
- agentenv_framework_protocol-0.1.386/tests/test_computer_conformance_recording.py +208 -0
- agentenv_framework_protocol-0.1.386/tests/test_computer_recording.py +490 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/_declare.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/client.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/conformance/report.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/errors.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/frames.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/keys.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/linux.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/platform.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/queue.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/results.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/settings.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/spaces.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/computer/tools.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/errors.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/computer_fake.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/probe_fake.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_proxy.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_conformance_checks.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_declare.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_driver.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_extensions.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_frames.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_queue.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_computer_spaces.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_errors.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_extension_methods.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_annotations.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.381 → agentenv_framework_protocol-0.1.386}/tests/test_tool_context_fastmcp.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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:
|