agentenv-framework-protocol 0.1.318__tar.gz → 0.1.320__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 (41) hide show
  1. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/PKG-INFO +81 -8
  2. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/README.md +80 -7
  3. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/__init__.py +10 -0
  5. agentenv_framework_protocol-0.1.320/src/agentenv_protocol/a2a_agent/_trajectory_log.py +244 -0
  6. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/extensions.py +79 -3
  7. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/framework.py +93 -48
  8. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +2 -0
  9. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/tasks/v1.py +67 -1
  10. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_a2a_agent.py +19 -14
  11. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_a2a_object_transfers.py +5 -4
  12. agentenv_framework_protocol-0.1.320/tests/test_a2a_trajectory_log.py +634 -0
  13. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/.gitignore +0 -0
  14. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/LICENSE +0 -0
  15. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/NOTICE +0 -0
  16. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/THIRD_PARTY_NOTICES.md +0 -0
  17. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/__init__.py +0 -0
  18. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/advanced_agent.py +0 -0
  19. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/basic_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/custom_extensions_agent.py +0 -0
  21. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/multimodal_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/reference_model.py +0 -0
  23. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/examples/streaming_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/__init__.py +0 -0
  25. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  26. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  27. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  28. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/agent_env_environment.py +0 -0
  29. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/client.py +0 -0
  30. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/manifest.py +0 -0
  31. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/preflight.py +0 -0
  32. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/transfers.py +0 -0
  33. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/src/agentenv_protocol/types.py +0 -0
  34. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_a2a_agent_examples.py +0 -0
  35. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_a2a_staging.py +0 -0
  36. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_agent_env_environment.py +0 -0
  37. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_client.py +0 -0
  38. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_data_objects.py +0 -0
  39. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_intake.py +0 -0
  40. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_manifest.py +0 -0
  41. {agentenv_framework_protocol-0.1.318 → agentenv_framework_protocol-0.1.320}/tests/test_preflight.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.318
3
+ Version: 0.1.320
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
@@ -324,8 +324,9 @@ executor maps them to and from the wire-level `a2a.types.Task` lifecycle.
324
324
  `TaskRequest` is a frozen record whose nested JSON values are detached copies.
325
325
  Its `config`, `mcp_servers`, `skills`, `metadata`, and inbound `DataPart.data`
326
326
  retain their declared `dict`/`list` types, so normal Pydantic serialization,
327
- copying, and `json.dumps(...)` work. Within `tasks.v1`, new request fields are
328
- additive and have framework defaults. Agent code returns a `TaskResult` through
327
+ copying, and `json.dumps(...)` work. `TaskRequest.trajectory` is the exception:
328
+ it is the task's live trajectory log, described below. Within `tasks.v1`, new
329
+ request fields are additive and have framework defaults. Agent code returns a `TaskResult` through
329
330
  its factories or builder so additions to the result contract do not break
330
331
  existing handlers.
331
332
 
@@ -348,7 +349,77 @@ The SDK does not retry tasks.
348
349
  A client that gives up on a task sends `tasks/cancel`. The framework marks the
349
350
  task canceled and cancels `run()`, which gets `asyncio.CancelledError` at its
350
351
  next `await`. A process `run()` started keeps running unless `run()` stops it,
351
- so kill it before re-raising.
352
+ so kill it before re-raising. Trajectory events it appended, including any it
353
+ appends while cleaning up, are kept, and its log ends `canceled`.
354
+
355
+ #### Live trajectories
356
+
357
+ A runtime that sees its native events as they happen appends each complete
358
+ event to `request.trajectory` and enables `TRAJECTORY_V1` with `live=True`:
359
+
360
+ ```python
361
+ @a2a_agent(identity=..., extensions=(enable(TRAJECTORY_V1, live=True),))
362
+ class MyAgent(AgentEnvAgent):
363
+ async def run(self, request: TaskRequest[MyAgentConfig]) -> TaskResult:
364
+ request.trajectory.set_format("my-cli-events/1")
365
+ execution = await run_my_cli(request, on_event=request.trajectory.append)
366
+ return TaskResult.text(execution.output)
367
+ ```
368
+
369
+ `append(event)` takes a JSON object and serializes it at once, so an event
370
+ never changes after it is appended and a read never returns part of one. It does
371
+ no I/O; call it from one thread at a time. After the task ends the log refuses
372
+ further events. `set_format` names the events' versioned format, which readers
373
+ use to parse them; call it before the first `append`, which otherwise raises.
374
+ Once an event is appended the format cannot change.
375
+
376
+ The framework keeps each task's log and serves it while the task runs. When the
377
+ result carries no `native_trajectory`, the log is the task's final trajectory:
378
+ the completed-task reads `{task_id}` and `{task_id, objects}` return it as a JSON
379
+ array. A `native_trajectory` remains the final record of a runtime that can only
380
+ produce one at the end, and takes precedence over appended events.
381
+
382
+ `live=True` adds two cursor reads to `TRAJECTORY_V1.get`, both framework-owned:
383
+
384
+ | Request | Reads |
385
+ | --- | --- |
386
+ | `{task_id, after, limit?}` | one task's events |
387
+ | `{context_id, after, limit?}` | the events of every task in a context, in execution order |
388
+
389
+ Both answer `{context_id, task_id, state, format, events, next, has_more}`
390
+ (`TrajectoryEventsResponse`):
391
+
392
+ - `events` are the events from position `after`, zero-based. `next` is
393
+ `after` plus the number returned, the position to read from next; `has_more`
394
+ says more events are already readable past this page. A page holds at most
395
+ `limit` events (1 to 1,000, default 1,000) and about 4 MiB, but always at
396
+ least one event when one exists. A `limit` outside 1 to 1,000, or `after` past
397
+ the last event, is HTTP 400.
398
+ - `state` is `pending` before the first event, `running` while the task runs,
399
+ then `completed`, `failed` or `canceled`. A terminal state is reported only
400
+ once the log can no longer change, so a reader that has reached `has_more:
401
+ false` in a terminal state has every event. The terminal state is recorded
402
+ before the task's terminal A2A status, so the completed-task reads succeed as
403
+ soon as a client sees the task end.
404
+ - A context read reports the context's current task, the running one or else the
405
+ latest, and the most recent `format` any of its tasks set; its `state` is
406
+ `running` while any task runs.
407
+ - A task or context the agent has not seen, or no longer keeps, is HTTP 404. A
408
+ task is known from the moment `message/send` returns its id, so a 404 for an
409
+ id that was readable means it was evicted, and any cursor held for it, or for
410
+ its context, is no longer valid.
411
+
412
+ `live=True` also enables the context reads `{context_id}` and
413
+ `{context_id, objects}`; the framework answers them with the context's events
414
+ unless the agent binds its own handlers, which take precedence. An agent that
415
+ overrides the whole `get` operation must accept the cursor requests too.
416
+ Clients detect live support from the card, with
417
+ `card_request_accepts(get_request, {"task_id", "after"})`.
418
+
419
+ The logs are kept in memory for up to 1,024 contexts. Past that, whole contexts
420
+ are dropped, least recently used first; a context with a task still pending or
421
+ running is never dropped, nor is the one whose task has just ended. A running
422
+ task's log is logged as a warning once it passes 100 MiB.
352
423
 
353
424
  `enable(..., description="...")` is reserved for declarations carrying
354
425
  configuration or metadata. It preserves the agent-specific extension prose
@@ -363,7 +434,8 @@ entry in `extensions=` is required.
363
434
  Configuration keywords are definition-owned rather than hardcoded in
364
435
  `enable()`. A configurable `ExtensionDefinition` supplies a named keyword-only
365
436
  `configuration_validator` returning `ExtensionConfiguration` with card
366
- `wire_params`, internal `options`, and optional `features`. `enable()` only
437
+ `wire_params`, internal `options`, and optional `features` and request
438
+ `variants`. `enable()` only
367
439
  dispatches to that callable. Definitions without a validator reject
368
440
  configuration keywords, and third-party definitions use the same public API as
369
441
  the built-ins.
@@ -460,9 +532,9 @@ async def get_live_trajectory(self, request: ContextObjectTrajectoryRequest):
460
532
  The last handler opts that agent into the optional live-context variant of
461
533
  `TRAJECTORY_V1.get` that uploads through an object grant;
462
534
  `TRAJECTORY_V1.get.context` (`ContextTrajectoryRequest`) is its inline
463
- counterpart. Without them the generated card advertises only the
464
- framework-owned completed-task variants: `{task_id}`, answered inline, and
465
- `{task_id, objects}`, answered by upload.
535
+ counterpart. Without them, or `enable(TRAJECTORY_V1, live=True)`, the
536
+ generated card advertises only the framework-owned completed-task variants:
537
+ `{task_id}`, answered inline, and `{task_id, objects}`, answered by upload.
466
538
  Snapshot `save` and `load` are an atomic core contract, while its optional
467
539
  changelog handlers are enabled as an atomic feature group.
468
540
  Each operation has at most one response model. The framework validates a
@@ -600,6 +672,7 @@ optional field, `|` an alternative request):
600
672
  | skill `add` | `{name, description, skill_md}` \| `{name, description, skill_bundle: {max_total_bytes, files: [{path, object: ReadObject}]}}` | `{name}` |
601
673
  | trajectory `get` | `{task_id}` \| `{context_id}` | `{trajectory}` |
602
674
  | | `{task_id \| context_id, objects: {trajectory: WriteObject}}` | `{objects: {trajectory: Uploaded}}` |
675
+ | | `{task_id \| context_id, after, limit?}` | `{context_id, task_id, state, format, events, next, has_more}` |
603
676
  | snapshot `save` | `{context_id, objects: {trajectory: WriteObject, workspace?: WriteObject}}` | `{context_id, objects: {trajectory: Uploaded, workspace?: Uploaded}}` |
604
677
  | snapshot `load` | `{objects: {trajectory: ReadObject, workspace?: ReadObject}, target_context_id?}` | `{context_id}` |
605
678
  | `enable-changelog` | `{write_namespace: WriteNamespaceGrant, roots?}` | `{roots}` |
@@ -280,8 +280,9 @@ executor maps them to and from the wire-level `a2a.types.Task` lifecycle.
280
280
  `TaskRequest` is a frozen record whose nested JSON values are detached copies.
281
281
  Its `config`, `mcp_servers`, `skills`, `metadata`, and inbound `DataPart.data`
282
282
  retain their declared `dict`/`list` types, so normal Pydantic serialization,
283
- copying, and `json.dumps(...)` work. Within `tasks.v1`, new request fields are
284
- additive and have framework defaults. Agent code returns a `TaskResult` through
283
+ copying, and `json.dumps(...)` work. `TaskRequest.trajectory` is the exception:
284
+ it is the task's live trajectory log, described below. Within `tasks.v1`, new
285
+ request fields are additive and have framework defaults. Agent code returns a `TaskResult` through
285
286
  its factories or builder so additions to the result contract do not break
286
287
  existing handlers.
287
288
 
@@ -304,7 +305,77 @@ The SDK does not retry tasks.
304
305
  A client that gives up on a task sends `tasks/cancel`. The framework marks the
305
306
  task canceled and cancels `run()`, which gets `asyncio.CancelledError` at its
306
307
  next `await`. A process `run()` started keeps running unless `run()` stops it,
307
- so kill it before re-raising.
308
+ so kill it before re-raising. Trajectory events it appended, including any it
309
+ appends while cleaning up, are kept, and its log ends `canceled`.
310
+
311
+ #### Live trajectories
312
+
313
+ A runtime that sees its native events as they happen appends each complete
314
+ event to `request.trajectory` and enables `TRAJECTORY_V1` with `live=True`:
315
+
316
+ ```python
317
+ @a2a_agent(identity=..., extensions=(enable(TRAJECTORY_V1, live=True),))
318
+ class MyAgent(AgentEnvAgent):
319
+ async def run(self, request: TaskRequest[MyAgentConfig]) -> TaskResult:
320
+ request.trajectory.set_format("my-cli-events/1")
321
+ execution = await run_my_cli(request, on_event=request.trajectory.append)
322
+ return TaskResult.text(execution.output)
323
+ ```
324
+
325
+ `append(event)` takes a JSON object and serializes it at once, so an event
326
+ never changes after it is appended and a read never returns part of one. It does
327
+ no I/O; call it from one thread at a time. After the task ends the log refuses
328
+ further events. `set_format` names the events' versioned format, which readers
329
+ use to parse them; call it before the first `append`, which otherwise raises.
330
+ Once an event is appended the format cannot change.
331
+
332
+ The framework keeps each task's log and serves it while the task runs. When the
333
+ result carries no `native_trajectory`, the log is the task's final trajectory:
334
+ the completed-task reads `{task_id}` and `{task_id, objects}` return it as a JSON
335
+ array. A `native_trajectory` remains the final record of a runtime that can only
336
+ produce one at the end, and takes precedence over appended events.
337
+
338
+ `live=True` adds two cursor reads to `TRAJECTORY_V1.get`, both framework-owned:
339
+
340
+ | Request | Reads |
341
+ | --- | --- |
342
+ | `{task_id, after, limit?}` | one task's events |
343
+ | `{context_id, after, limit?}` | the events of every task in a context, in execution order |
344
+
345
+ Both answer `{context_id, task_id, state, format, events, next, has_more}`
346
+ (`TrajectoryEventsResponse`):
347
+
348
+ - `events` are the events from position `after`, zero-based. `next` is
349
+ `after` plus the number returned, the position to read from next; `has_more`
350
+ says more events are already readable past this page. A page holds at most
351
+ `limit` events (1 to 1,000, default 1,000) and about 4 MiB, but always at
352
+ least one event when one exists. A `limit` outside 1 to 1,000, or `after` past
353
+ the last event, is HTTP 400.
354
+ - `state` is `pending` before the first event, `running` while the task runs,
355
+ then `completed`, `failed` or `canceled`. A terminal state is reported only
356
+ once the log can no longer change, so a reader that has reached `has_more:
357
+ false` in a terminal state has every event. The terminal state is recorded
358
+ before the task's terminal A2A status, so the completed-task reads succeed as
359
+ soon as a client sees the task end.
360
+ - A context read reports the context's current task, the running one or else the
361
+ latest, and the most recent `format` any of its tasks set; its `state` is
362
+ `running` while any task runs.
363
+ - A task or context the agent has not seen, or no longer keeps, is HTTP 404. A
364
+ task is known from the moment `message/send` returns its id, so a 404 for an
365
+ id that was readable means it was evicted, and any cursor held for it, or for
366
+ its context, is no longer valid.
367
+
368
+ `live=True` also enables the context reads `{context_id}` and
369
+ `{context_id, objects}`; the framework answers them with the context's events
370
+ unless the agent binds its own handlers, which take precedence. An agent that
371
+ overrides the whole `get` operation must accept the cursor requests too.
372
+ Clients detect live support from the card, with
373
+ `card_request_accepts(get_request, {"task_id", "after"})`.
374
+
375
+ The logs are kept in memory for up to 1,024 contexts. Past that, whole contexts
376
+ are dropped, least recently used first; a context with a task still pending or
377
+ running is never dropped, nor is the one whose task has just ended. A running
378
+ task's log is logged as a warning once it passes 100 MiB.
308
379
 
309
380
  `enable(..., description="...")` is reserved for declarations carrying
310
381
  configuration or metadata. It preserves the agent-specific extension prose
@@ -319,7 +390,8 @@ entry in `extensions=` is required.
319
390
  Configuration keywords are definition-owned rather than hardcoded in
320
391
  `enable()`. A configurable `ExtensionDefinition` supplies a named keyword-only
321
392
  `configuration_validator` returning `ExtensionConfiguration` with card
322
- `wire_params`, internal `options`, and optional `features`. `enable()` only
393
+ `wire_params`, internal `options`, and optional `features` and request
394
+ `variants`. `enable()` only
323
395
  dispatches to that callable. Definitions without a validator reject
324
396
  configuration keywords, and third-party definitions use the same public API as
325
397
  the built-ins.
@@ -416,9 +488,9 @@ async def get_live_trajectory(self, request: ContextObjectTrajectoryRequest):
416
488
  The last handler opts that agent into the optional live-context variant of
417
489
  `TRAJECTORY_V1.get` that uploads through an object grant;
418
490
  `TRAJECTORY_V1.get.context` (`ContextTrajectoryRequest`) is its inline
419
- counterpart. Without them the generated card advertises only the
420
- framework-owned completed-task variants: `{task_id}`, answered inline, and
421
- `{task_id, objects}`, answered by upload.
491
+ counterpart. Without them, or `enable(TRAJECTORY_V1, live=True)`, the
492
+ generated card advertises only the framework-owned completed-task variants:
493
+ `{task_id}`, answered inline, and `{task_id, objects}`, answered by upload.
422
494
  Snapshot `save` and `load` are an atomic core contract, while its optional
423
495
  changelog handlers are enabled as an atomic feature group.
424
496
  Each operation has at most one response model. The framework validates a
@@ -556,6 +628,7 @@ optional field, `|` an alternative request):
556
628
  | skill `add` | `{name, description, skill_md}` \| `{name, description, skill_bundle: {max_total_bytes, files: [{path, object: ReadObject}]}}` | `{name}` |
557
629
  | trajectory `get` | `{task_id}` \| `{context_id}` | `{trajectory}` |
558
630
  | | `{task_id \| context_id, objects: {trajectory: WriteObject}}` | `{objects: {trajectory: Uploaded}}` |
631
+ | | `{task_id \| context_id, after, limit?}` | `{context_id, task_id, state, format, events, next, has_more}` |
559
632
  | snapshot `save` | `{context_id, objects: {trajectory: WriteObject, workspace?: WriteObject}}` | `{context_id, objects: {trajectory: Uploaded, workspace?: Uploaded}}` |
560
633
  | snapshot `load` | `{objects: {trajectory: ReadObject, workspace?: ReadObject}, target_context_id?}` | `{context_id}` |
561
634
  | `enable-changelog` | `{write_namespace: WriteNamespaceGrant, roots?}` | `{roots}` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.318"
3
+ version = "0.1.320"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -16,6 +16,7 @@ from .extensions import (
16
16
  AttributionProbeResponse,
17
17
  BundleSkillRequest,
18
18
  ChangelogIncrement,
19
+ ContextEventsTrajectoryRequest,
19
20
  ContextObjectTrajectoryRequest,
20
21
  ContextTrajectoryRequest,
21
22
  ExtensionActivation,
@@ -48,9 +49,12 @@ from .extensions import (
48
49
  SnapshotReadObjects,
49
50
  SnapshotUploadedObjects,
50
51
  SnapshotWriteObjects,
52
+ TaskEventsTrajectoryRequest,
51
53
  TaskObjectTrajectoryRequest,
52
54
  TaskTrajectoryRequest,
55
+ TrajectoryEventsResponse,
53
56
  TrajectoryObjectsResponse,
57
+ TrajectoryState,
54
58
  TrajectoryUploadedObjects,
55
59
  TrajectoryWriteObjects,
56
60
  TriggerDecideRequest,
@@ -92,6 +96,7 @@ from .tasks.v1 import (
92
96
  TaskResultBuilder,
93
97
  TaskStreamItem,
94
98
  TextPart,
99
+ TrajectoryLog,
95
100
  Usage,
96
101
  WriteOnly,
97
102
  )
@@ -131,6 +136,7 @@ __all__ = [
131
136
  "AttributionProbeResponse",
132
137
  "BundleSkillRequest",
133
138
  "ChangelogIncrement",
139
+ "ContextEventsTrajectoryRequest",
134
140
  "ContextObjectTrajectoryRequest",
135
141
  "ContextTrajectoryRequest",
136
142
  "DataPart",
@@ -177,6 +183,7 @@ __all__ = [
177
183
  "SnapshotWriteObjects",
178
184
  "StagingStore",
179
185
  "TaskError",
186
+ "TaskEventsTrajectoryRequest",
180
187
  "TaskObjectTrajectoryRequest",
181
188
  "TaskOutcome",
182
189
  "TaskPart",
@@ -187,7 +194,10 @@ __all__ = [
187
194
  "TaskStreamItem",
188
195
  "TaskTrajectoryRequest",
189
196
  "TextPart",
197
+ "TrajectoryEventsResponse",
198
+ "TrajectoryLog",
190
199
  "TrajectoryObjectsResponse",
200
+ "TrajectoryState",
191
201
  "TrajectoryUploadedObjects",
192
202
  "TrajectoryWriteObjects",
193
203
  "TransferError",
@@ -0,0 +1,244 @@
1
+ """The framework's in-process store of task trajectories, served by the trajectory extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass
8
+
9
+ from .extensions import TrajectoryState
10
+ from .tasks.v1 import TrajectoryLog
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ MAX_CACHED_CONTEXTS = 1_024
15
+ MAX_TRAJECTORY_READ_BYTES = 4 * 1024 * 1024
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _TaskRecord:
20
+ context_id: str
21
+ log: TrajectoryLog
22
+ started: bool = False
23
+ cancel_requested: bool = False
24
+ final_state: TrajectoryState | None = None
25
+ native: bytes | None = None
26
+
27
+ @property
28
+ def state(self) -> TrajectoryState:
29
+ if self.final_state is not None:
30
+ return self.final_state
31
+ return TrajectoryState.RUNNING if len(self.log) else TrajectoryState.PENDING
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class TrajectoryPage:
36
+ context_id: str
37
+ task_id: str
38
+ state: TrajectoryState
39
+ format: str | None
40
+ events: list[bytes]
41
+ total: int
42
+
43
+
44
+ class TaskTrajectories:
45
+ """Each task's log and final record, grouped by context in execution order.
46
+
47
+ Past ``max_contexts``, whole contexts are evicted, least recently used first, so
48
+ positions in a context read never shift. A context with a task pending or running is
49
+ never evicted, nor is the one whose task just ended, so its final record is readable."""
50
+
51
+ def __init__(self, max_contexts: int = MAX_CACHED_CONTEXTS) -> None:
52
+ if max_contexts < 1:
53
+ raise ValueError("max_contexts must be positive")
54
+ self._max_contexts = max_contexts
55
+ self._tasks: dict[str, _TaskRecord] = {}
56
+ self._contexts: OrderedDict[str, list[str]] = OrderedDict()
57
+
58
+ def register(self, task_id: str, context_id: str) -> TrajectoryLog:
59
+ """The task's log, created ``pending`` the first time the task is seen."""
60
+ record = self._tasks.get(task_id)
61
+ if record is None:
62
+ log = TrajectoryLog()
63
+ log._task_id = task_id
64
+ record = self._tasks[task_id] = _TaskRecord(context_id=context_id, log=log)
65
+ self._contexts.setdefault(context_id, []).append(task_id)
66
+ self._touch(record.context_id)
67
+ return record.log
68
+
69
+ def start(self, task_id: str) -> None:
70
+ """Place the task after the context's started tasks, as it begins to run.
71
+
72
+ Only tasks that have not started move; they have no events, so no position shifts."""
73
+ record = self._tasks[task_id]
74
+ if record.started:
75
+ return
76
+ order = self._contexts[record.context_id]
77
+ order.remove(task_id)
78
+ started = sum(1 for other in order if self._tasks[other].started)
79
+ order.insert(started, task_id)
80
+ record.started = True
81
+ self._touch(record.context_id)
82
+
83
+ def request_cancel(self, task_id: str) -> None:
84
+ """Make ``canceled`` the outcome the task is sealed with, whatever ``run`` returns."""
85
+ record = self._tasks.get(task_id)
86
+ if record is not None:
87
+ record.cancel_requested = True
88
+
89
+ def seal(
90
+ self, task_id: str, state: TrajectoryState, *, native: bytes | None = None
91
+ ) -> None:
92
+ """End the task's trajectory; the first seal wins."""
93
+ record = self._tasks[task_id]
94
+ if record.final_state is not None:
95
+ return
96
+ if native is not None:
97
+ if len(record.log):
98
+ logger.warning(
99
+ "task %s appended trajectory events and returned a native trajectory; "
100
+ "the native trajectory is its final record",
101
+ task_id,
102
+ )
103
+ record.native = native
104
+ record.log._sealed = True
105
+ record.final_state = TrajectoryState.CANCELED if record.cancel_requested else state
106
+ self._touch(record.context_id)
107
+ self._evict(keep=record.context_id)
108
+
109
+ def final(self, task_id: str) -> bytes | None:
110
+ """The ended task's final trajectory as a JSON array, or None if there is none yet."""
111
+ record = self._tasks.get(task_id)
112
+ if record is None or record.final_state is None:
113
+ return None
114
+ self._touch(record.context_id)
115
+ if record.native is not None:
116
+ return record.native
117
+ if not len(record.log):
118
+ return None
119
+ return _json_array(record.log._events)
120
+
121
+ def context_events(self, context_id: str) -> bytes | None:
122
+ """Every event of the context's tasks in order, as a JSON array."""
123
+ records = self._context_records(context_id)
124
+ if records is None:
125
+ return None
126
+ return _json_array([event for record in records for event in record.log._events])
127
+
128
+ def task_page(
129
+ self, task_id: str, after: int, max_events: int, max_bytes: int
130
+ ) -> TrajectoryPage | None:
131
+ record = self._tasks.get(task_id)
132
+ if record is None:
133
+ return None
134
+ self._touch(record.context_id)
135
+ # State before length: a terminal state means the length can no longer grow.
136
+ state = record.state
137
+ events = record.log._events
138
+ total = len(events)
139
+ return TrajectoryPage(
140
+ context_id=record.context_id,
141
+ task_id=task_id,
142
+ state=state,
143
+ format=record.log.format,
144
+ events=_page(events, after, total, max_events, max_bytes),
145
+ total=total,
146
+ )
147
+
148
+ def context_page(
149
+ self, context_id: str, after: int, max_events: int, max_bytes: int
150
+ ) -> TrajectoryPage | None:
151
+ records = self._context_records(context_id)
152
+ if records is None:
153
+ return None
154
+ task_ids = self._contexts[context_id]
155
+ states = [record.state for record in records]
156
+ lengths = [len(record.log) for record in records]
157
+ current = next(
158
+ (index for index, record in enumerate(records) if record.final_state is None),
159
+ len(records) - 1,
160
+ )
161
+ if TrajectoryState.RUNNING in states:
162
+ state = TrajectoryState.RUNNING
163
+ elif TrajectoryState.PENDING in states:
164
+ state = TrajectoryState.PENDING
165
+ else:
166
+ state = states[-1]
167
+ page: list[bytes] = []
168
+ offset = 0
169
+ for record, length in zip(records, lengths):
170
+ if len(page) >= max_events:
171
+ break
172
+ if after < offset + length:
173
+ start = max(after - offset, 0)
174
+ page.extend(
175
+ _page(
176
+ record.log._events,
177
+ start,
178
+ length,
179
+ max_events - len(page),
180
+ max_bytes - sum(map(len, page)),
181
+ first=not page,
182
+ )
183
+ )
184
+ offset += length
185
+ return TrajectoryPage(
186
+ context_id=context_id,
187
+ task_id=task_ids[current],
188
+ state=state,
189
+ format=next(
190
+ (record.log.format for record in reversed(records) if record.log.format),
191
+ None,
192
+ ),
193
+ events=page,
194
+ total=sum(lengths),
195
+ )
196
+
197
+ def _context_records(self, context_id: str) -> list[_TaskRecord] | None:
198
+ task_ids = self._contexts.get(context_id)
199
+ if not task_ids:
200
+ return None
201
+ self._touch(context_id)
202
+ return [self._tasks[task_id] for task_id in task_ids]
203
+
204
+ def _touch(self, context_id: str) -> None:
205
+ if context_id in self._contexts:
206
+ self._contexts.move_to_end(context_id)
207
+
208
+ def _evict(self, *, keep: str) -> None:
209
+ for context_id in tuple(self._contexts):
210
+ if len(self._contexts) <= self._max_contexts:
211
+ return
212
+ task_ids = self._contexts[context_id]
213
+ if context_id == keep or any(
214
+ self._tasks[task_id].final_state is None for task_id in task_ids
215
+ ):
216
+ continue
217
+ del self._contexts[context_id]
218
+ for task_id in task_ids:
219
+ del self._tasks[task_id]
220
+
221
+
222
+ def _page(
223
+ events: list[bytes],
224
+ start: int,
225
+ stop: int,
226
+ max_events: int,
227
+ max_bytes: int,
228
+ *,
229
+ first: bool = True,
230
+ ) -> list[bytes]:
231
+ """Events from ``start``, within both budgets; the first event of a read is always
232
+ returned, so one larger than ``max_bytes`` cannot stall a reader."""
233
+ page: list[bytes] = []
234
+ used = 0
235
+ for encoded in events[start:min(stop, start + max_events)]:
236
+ if used + len(encoded) > max_bytes and (page or not first):
237
+ break
238
+ page.append(encoded)
239
+ used += len(encoded)
240
+ return page
241
+
242
+
243
+ def _json_array(events: list[bytes]) -> bytes:
244
+ return b"[" + b",".join(events) + b"]"