opencode-a2a 0.4.1__tar.gz → 0.5.0__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 (133) hide show
  1. {opencode_a2a-0.4.1/src/opencode_a2a.egg-info → opencode_a2a-0.5.0}/PKG-INFO +3 -1
  2. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/docs/guide.md +35 -5
  3. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/pyproject.toml +2 -0
  4. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/config.py +29 -3
  5. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/contracts/extensions.py +3 -2
  6. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/executor.py +6 -1
  7. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/session_manager.py +42 -25
  8. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/stream_events.py +1 -1
  9. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/stream_runtime.py +3 -3
  10. opencode_a2a-0.4.1/src/opencode_a2a/execution/upstream_errors.py → opencode_a2a-0.5.0/src/opencode_a2a/execution/upstream_error_translator.py +3 -3
  11. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/application.py +5 -5
  12. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/opencode_upstream_client.py +32 -79
  13. opencode_a2a-0.5.0/src/opencode_a2a/runtime_state.py +20 -0
  14. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/server/application.py +53 -6
  15. opencode_a2a-0.5.0/src/opencode_a2a/server/state_store.py +664 -0
  16. opencode_a2a-0.5.0/src/opencode_a2a/server/task_store.py +116 -0
  17. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0/src/opencode_a2a.egg-info}/PKG-INFO +3 -1
  18. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/SOURCES.txt +11 -4
  19. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/requires.txt +2 -0
  20. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/config/test_settings.py +15 -0
  21. opencode_a2a-0.5.0/tests/conftest.py +36 -0
  22. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/contracts/test_extension_contract_consistency.py +1 -1
  23. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_cancellation.py +22 -2
  24. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_metrics.py +2 -2
  25. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_multipart_input.py +3 -3
  26. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_opencode_agent_session_binding.py +2 -12
  27. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_session_ownership.py +161 -15
  28. opencode_a2a-0.4.1/tests/jsonrpc/test_error_mapping.py → opencode_a2a-0.5.0/tests/jsonrpc/test_error_responses.py +1 -1
  29. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_interrupts.py +7 -7
  30. opencode_a2a-0.5.0/tests/server/test_database_app_persistence.py +210 -0
  31. opencode_a2a-0.5.0/tests/server/test_state_store.py +193 -0
  32. opencode_a2a-0.5.0/tests/server/test_task_store_factory.py +78 -0
  33. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_transport_contract.py +42 -12
  34. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/support/helpers.py +12 -7
  35. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/support/streaming_output.py +3 -3
  36. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/upstream/test_opencode_upstream_client_params.py +21 -21
  37. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/uv.lock +113 -0
  38. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.github/workflows/ci.yml +0 -0
  39. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.github/workflows/dependency-health.yml +0 -0
  40. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.github/workflows/publish.yml +0 -0
  41. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.gitignore +0 -0
  42. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.pre-commit-config.yaml +0 -0
  43. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/.secrets.baseline +0 -0
  44. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/AGENTS.md +0 -0
  45. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/CONTRIBUTING.md +0 -0
  46. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/LICENSE +0 -0
  47. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/README.md +0 -0
  48. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/SECURITY.md +0 -0
  49. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/README.md +0 -0
  50. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/check_coverage.py +0 -0
  51. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/dependency_health.sh +0 -0
  52. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/doctor.sh +0 -0
  53. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/health_common.sh +0 -0
  54. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/lint.sh +0 -0
  55. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/scripts/smoke_test_built_cli.sh +0 -0
  56. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/setup.cfg +0 -0
  57. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/__init__.py +0 -0
  58. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/cli.py +0 -0
  59. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/__init__.py +0 -0
  60. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/agent_card.py +0 -0
  61. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/client.py +0 -0
  62. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/config.py +0 -0
  63. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/error_mapping.py +0 -0
  64. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/errors.py +0 -0
  65. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/payload_text.py +0 -0
  66. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/client/request_context.py +0 -0
  67. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/contracts/__init__.py +0 -0
  68. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/__init__.py +0 -0
  69. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/event_helpers.py +0 -0
  70. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/request_context.py +0 -0
  71. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/stream_state.py +0 -0
  72. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/tool_error_mapping.py +0 -0
  73. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/__init__.py +0 -0
  74. /opencode_a2a-0.4.1/src/opencode_a2a/jsonrpc/error_mapping.py → /opencode_a2a-0.5.0/src/opencode_a2a/jsonrpc/error_responses.py +0 -0
  75. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/methods.py +0 -0
  76. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/params.py +0 -0
  77. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/__init__.py +0 -0
  78. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/mapping.py +0 -0
  79. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/text.py +0 -0
  80. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/profile/__init__.py +0 -0
  81. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/profile/runtime.py +0 -0
  82. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/sandbox_policy.py +0 -0
  83. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/server/__init__.py +0 -0
  84. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/server/agent_card.py +0 -0
  85. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/server/openapi.py +0 -0
  86. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a/server/request_parsing.py +0 -0
  87. /opencode_a2a-0.4.1/src/opencode_a2a/error_taxonomy.py → /opencode_a2a-0.5.0/src/opencode_a2a/upstream_taxonomy.py +0 -0
  88. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/dependency_links.txt +0 -0
  89. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/entry_points.txt +0 -0
  90. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/top_level.txt +0 -0
  91. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/__init__.py +0 -0
  92. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/__init__.py +0 -0
  93. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_agent_card.py +0 -0
  94. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_client_config.py +0 -0
  95. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_client_facade.py +0 -0
  96. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_error_mapping.py +0 -0
  97. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_payload_text.py +0 -0
  98. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/client/test_request_context.py +0 -0
  99. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/config/__init__.py +0 -0
  100. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/contracts/__init__.py +0 -0
  101. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/__init__.py +0 -0
  102. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_agent_errors.py +0 -0
  103. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_agent_helpers.py +0 -0
  104. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_directory_validation.py +0 -0
  105. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_blocks.py +0 -0
  106. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_core.py +0 -0
  107. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_interrupts.py +0 -0
  108. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_logging.py +0 -0
  109. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/__init__.py +0 -0
  110. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_methods.py +0 -0
  111. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_params.py +0 -0
  112. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_unsupported_method.py +0 -0
  113. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_commands.py +0 -0
  114. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +0 -0
  115. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_queries.py +0 -0
  116. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/package/__init__.py +0 -0
  117. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/package/test_version.py +0 -0
  118. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/parts/__init__.py +0 -0
  119. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/parts/test_parts_text.py +0 -0
  120. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/profile/__init__.py +0 -0
  121. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/profile/test_profile_runtime.py +0 -0
  122. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/scripts/__init__.py +0 -0
  123. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/scripts/test_script_health_contract.py +0 -0
  124. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/__init__.py +0 -0
  125. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_a2a_client_manager.py +0 -0
  126. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_agent_card.py +0 -0
  127. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_app_behaviors.py +0 -0
  128. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_call_context_builder.py +0 -0
  129. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_cancel_contract.py +0 -0
  130. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/server/test_cli.py +0 -0
  131. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/support/__init__.py +0 -0
  132. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/support/session_extensions.py +0 -0
  133. {opencode_a2a-0.4.1 → opencode_a2a-0.5.0}/tests/upstream/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-a2a
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: OpenCode A2A runtime
5
5
  Author: liujuanjuan1984@Intelligent-Internet
6
6
  License-Expression: Apache-2.0
@@ -20,10 +20,12 @@ Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: a2a-sdk==0.3.25
23
+ Requires-Dist: aiosqlite<1.0,>=0.20
23
24
  Requires-Dist: fastapi<1.0,>=0.110
24
25
  Requires-Dist: httpx<1.0,>=0.27
25
26
  Requires-Dist: pydantic<3.0,>=2.6
26
27
  Requires-Dist: pydantic-settings<3.0,>=2.2
28
+ Requires-Dist: sqlalchemy<3.0,>=2.0
27
29
  Requires-Dist: sse-starlette<4.0,>=2.1
28
30
  Requires-Dist: uvicorn<1.0,>=0.29
29
31
  Provides-Extra: dev
@@ -55,10 +55,10 @@ Key variables to understand protocol behavior:
55
55
  also logged with preview truncation controlled by `A2A_LOG_BODY_LIMIT`.
56
56
  - `A2A_MAX_REQUEST_BODY_BYTES`: runtime request-body limit. Oversized requests
57
57
  return HTTP `413`.
58
- - `A2A_SESSION_CACHE_TTL_SECONDS` / `A2A_SESSION_CACHE_MAXSIZE`: session cache
59
- behavior for `(identity, contextId) -> session_id`.
58
+ - `A2A_PENDING_SESSION_CLAIM_TTL_SECONDS`: lease duration for pending preferred
59
+ session claims before they expire and stop blocking other identities.
60
60
  - `A2A_INTERRUPT_REQUEST_TTL_SECONDS`: active retention window for the
61
- in-memory interrupt request binding cache used by `a2a.interrupt.*`
61
+ interrupt request binding registry used by `a2a.interrupt.*`
62
62
  callback methods. Default: `10800` seconds (`180` minutes).
63
63
  - `A2A_INTERRUPT_REQUEST_TOMBSTONE_TTL_SECONDS`: retention window for expired
64
64
  interrupt tombstones after active TTL has elapsed. During this window,
@@ -76,6 +76,12 @@ Key variables to understand protocol behavior:
76
76
  - `A2A_CLIENT_BEARER_TOKEN`: optional bearer token attached to outbound peer
77
77
  calls made by the embedded A2A client and `a2a_call` tool path.
78
78
  - `A2A_CLIENT_SUPPORTED_TRANSPORTS`: ordered outbound transport preference list.
79
+ - `A2A_TASK_STORE_BACKEND`: runtime state backend. Supported values: `database`,
80
+ `memory`. Default: `database`.
81
+ - `A2A_TASK_STORE_DATABASE_URL`: database URL used by the default durable
82
+ backend. Default: `sqlite+aiosqlite:///./opencode-a2a.db`.
83
+ - `A2A_TASK_STORE_TABLE_NAME` / `A2A_TASK_STORE_CREATE_TABLE`: database task
84
+ store table name and whether to auto-create database tables on startup.
79
85
  - Runtime authentication is bearer-token only via `A2A_BEARER_TOKEN`.
80
86
  - The same outbound client flags are also honored by the server-side embedded
81
87
  A2A client used for peer calls and `a2a_call` tool execution:
@@ -157,6 +163,30 @@ OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
157
163
  opencode-a2a
158
164
  ```
159
165
 
166
+ By default, the service uses a SQLite-backed durable state store:
167
+
168
+ ```bash
169
+ OPENCODE_BASE_URL=http://127.0.0.1:4096 \
170
+ A2A_BEARER_TOKEN=dev-token \
171
+ A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \
172
+ opencode-a2a
173
+ ```
174
+
175
+ With the default `database` backend, the service persists:
176
+
177
+ - task records
178
+ - session binding / ownership state
179
+ - interrupt request bindings and tombstones
180
+
181
+ In-flight asyncio locks, outbound A2A client caches, and stream-local
182
+ aggregation buffers remain process-local runtime state.
183
+
184
+ To opt into an ephemeral development profile, set:
185
+
186
+ ```bash
187
+ A2A_TASK_STORE_BACKEND=memory
188
+ ```
189
+
160
190
  ## Troubleshooting Provider Auth State
161
191
 
162
192
  If one deployment works while another fails against the same upstream provider,
@@ -825,8 +855,8 @@ Notes:
825
855
 
826
856
  - `request_id` must be a live interrupt request observed from stream metadata
827
857
  (`metadata.shared.interrupt.request_id`).
828
- - The server keeps an in-memory interrupt binding cache; callbacks with unknown
829
- or expired `request_id` are rejected.
858
+ - The server keeps an interrupt binding registry; callbacks with unknown or
859
+ expired `request_id` are rejected.
830
860
  - The cache retention windows are controlled by
831
861
  `A2A_INTERRUPT_REQUEST_TTL_SECONDS` (default: `10800` seconds / `180`
832
862
  minutes) and `A2A_INTERRUPT_REQUEST_TOMBSTONE_TTL_SECONDS` (default: `600`
@@ -25,10 +25,12 @@ classifiers = [
25
25
  ]
26
26
  dependencies = [
27
27
  "a2a-sdk==0.3.25",
28
+ "aiosqlite>=0.20,<1.0",
28
29
  "fastapi>=0.110,<1.0",
29
30
  "httpx>=0.27,<1.0",
30
31
  "pydantic>=2.6,<3.0",
31
32
  "pydantic-settings>=2.2,<3.0",
33
+ "sqlalchemy>=2.0,<3.0",
32
34
  "sse-starlette>=2.1,<4.0",
33
35
  "uvicorn>=0.29,<1.0",
34
36
  ]
@@ -35,6 +35,7 @@ WriteAccessScope = Literal[
35
35
  "custom",
36
36
  ]
37
37
  OutsideWorkspaceAccess = Literal["unknown", "allowed", "disallowed", "custom"]
38
+ TaskStoreBackend = Literal["memory", "database"]
38
39
 
39
40
 
40
41
  def _parse_declared_list(value: Any) -> tuple[str, ...]:
@@ -132,9 +133,11 @@ class Settings(BaseSettings):
132
133
  a2a_port: int = Field(default=8000, alias="A2A_PORT")
133
134
  a2a_bearer_token: str = Field(..., min_length=1, alias="A2A_BEARER_TOKEN")
134
135
 
135
- # Session cache settings
136
- a2a_session_cache_ttl_seconds: int = Field(default=3600, alias="A2A_SESSION_CACHE_TTL_SECONDS")
137
- a2a_session_cache_maxsize: int = Field(default=10_000, alias="A2A_SESSION_CACHE_MAXSIZE")
136
+ a2a_pending_session_claim_ttl_seconds: float = Field(
137
+ default=30.0,
138
+ gt=0.0,
139
+ alias="A2A_PENDING_SESSION_CLAIM_TTL_SECONDS",
140
+ )
138
141
  a2a_interrupt_request_ttl_seconds: float = Field(
139
142
  default=10_800.0,
140
143
  ge=0.0,
@@ -176,7 +179,30 @@ class Settings(BaseSettings):
176
179
  alias="A2A_CLIENT_SUPPORTED_TRANSPORTS",
177
180
  )
178
181
 
182
+ # Task store settings
183
+ a2a_task_store_backend: TaskStoreBackend = Field(
184
+ default="database",
185
+ alias="A2A_TASK_STORE_BACKEND",
186
+ )
187
+ a2a_task_store_database_url: str | None = Field(
188
+ default="sqlite+aiosqlite:///./opencode-a2a.db",
189
+ alias="A2A_TASK_STORE_DATABASE_URL",
190
+ )
191
+ a2a_task_store_table_name: str = Field(
192
+ default="tasks",
193
+ min_length=1,
194
+ alias="A2A_TASK_STORE_TABLE_NAME",
195
+ )
196
+ a2a_task_store_create_table: bool = Field(
197
+ default=True,
198
+ alias="A2A_TASK_STORE_CREATE_TABLE",
199
+ )
200
+
179
201
  @model_validator(mode="after")
180
202
  def _validate_sandbox_policy(self) -> Settings:
181
203
  SandboxPolicy.from_settings(self).validate_configuration()
204
+ if self.a2a_task_store_backend == "database" and not self.a2a_task_store_database_url:
205
+ raise ValueError(
206
+ "A2A_TASK_STORE_DATABASE_URL is required when A2A_TASK_STORE_BACKEND=database"
207
+ )
182
208
  return self
@@ -450,8 +450,9 @@ def build_session_binding_extension_params(
450
450
  "message to that upstream session."
451
451
  ),
452
452
  (
453
- "Otherwise, the server will create a new upstream session and cache "
454
- "the (identity, contextId)->session_id mapping in memory with TTL."
453
+ "Otherwise, the server will create a new upstream session and retain "
454
+ "the (identity, contextId)->session_id mapping according to the "
455
+ "configured task/state store backend and TTL policy."
455
456
  ),
456
457
  ],
457
458
  }
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from ..server.application import A2AClientManager
14
+ from ..server.state_store import SessionStateRepository
14
15
 
15
16
  import httpx
16
17
  from a2a.server.agent_execution import AgentExecutor, RequestContext
@@ -72,7 +73,7 @@ from .stream_state import (
72
73
  _TTLCache,
73
74
  )
74
75
  from .tool_error_mapping import build_tool_error, map_a2a_tool_exception
75
- from .upstream_errors import (
76
+ from .upstream_error_translator import (
76
77
  _await_stream_terminal_signal,
77
78
  _extract_upstream_error_detail,
78
79
  _format_inband_upstream_error,
@@ -530,7 +531,9 @@ class OpencodeAgentExecutor(AgentExecutor):
530
531
  cancel_abort_timeout_seconds: float = 2.0,
531
532
  session_cache_ttl_seconds: int = 3600,
532
533
  session_cache_maxsize: int = 10_000,
534
+ pending_session_claim_ttl_seconds: float = 30.0,
533
535
  a2a_client_manager: A2AClientManager | None = None,
536
+ session_state_repository: SessionStateRepository | None = None,
534
537
  ) -> None:
535
538
  self._client = client
536
539
  self._streaming_enabled = streaming_enabled
@@ -544,6 +547,8 @@ class OpencodeAgentExecutor(AgentExecutor):
544
547
  client=client,
545
548
  session_cache_ttl_seconds=session_cache_ttl_seconds,
546
549
  session_cache_maxsize=session_cache_maxsize,
550
+ pending_session_claim_ttl_seconds=pending_session_claim_ttl_seconds,
551
+ state_repository=session_state_repository,
547
552
  )
548
553
  self._stream_runtime = StreamRuntime(
549
554
  client=client,
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
 
5
- from .stream_state import _TTLCache
5
+ from ..server.state_store import MemorySessionStateRepository, SessionStateRepository
6
6
 
7
7
 
8
8
  class SessionManager:
@@ -12,18 +12,15 @@ class SessionManager:
12
12
  client,
13
13
  session_cache_ttl_seconds: int = 3600,
14
14
  session_cache_maxsize: int = 10_000,
15
+ pending_session_claim_ttl_seconds: float = 30.0,
16
+ state_repository: SessionStateRepository | None = None,
15
17
  ) -> None:
16
18
  self._client = client
17
- self._sessions = _TTLCache(
19
+ self._state_repository = state_repository or MemorySessionStateRepository(
18
20
  ttl_seconds=session_cache_ttl_seconds,
19
21
  maxsize=session_cache_maxsize,
22
+ pending_claim_ttl_seconds=pending_session_claim_ttl_seconds,
20
23
  )
21
- self._session_owners = _TTLCache(
22
- ttl_seconds=session_cache_ttl_seconds,
23
- maxsize=session_cache_maxsize,
24
- refresh_on_get=True,
25
- )
26
- self._pending_session_claims: dict[str, str] = {}
27
24
  self._lock = asyncio.Lock()
28
25
  self._inflight_session_creates: dict[tuple[str, str], asyncio.Task[str]] = {}
29
26
  self._session_locks: dict[str, asyncio.Lock] = {}
@@ -43,13 +40,21 @@ class SessionManager:
43
40
  session_id=preferred_session_id,
44
41
  )
45
42
  if not pending_claim:
46
- self._sessions.set((identity, context_id), preferred_session_id)
43
+ async with self._lock:
44
+ await self._state_repository.set_session(
45
+ identity=identity,
46
+ context_id=context_id,
47
+ session_id=preferred_session_id,
48
+ )
47
49
  return preferred_session_id, pending_claim
48
50
 
49
51
  task: asyncio.Task[str] | None = None
50
52
  cache_key = (identity, context_id)
51
53
  async with self._lock:
52
- existing = self._sessions.get(cache_key)
54
+ existing = await self._state_repository.get_session(
55
+ identity=cache_key[0],
56
+ context_id=cache_key[1],
57
+ )
53
58
  if existing:
54
59
  return existing, False
55
60
  task = self._inflight_session_creates.get(cache_key)
@@ -68,14 +73,18 @@ class SessionManager:
68
73
  raise
69
74
 
70
75
  async with self._lock:
71
- owner = self._session_owners.get(session_id)
76
+ owner = await self._state_repository.get_owner(session_id=session_id)
72
77
  if owner and owner != identity:
73
78
  if self._inflight_session_creates.get(cache_key) is task:
74
79
  self._inflight_session_creates.pop(cache_key, None)
75
80
  raise PermissionError(f"Session {session_id} is not owned by you")
76
- self._sessions.set(cache_key, session_id)
81
+ await self._state_repository.set_session(
82
+ identity=cache_key[0],
83
+ context_id=cache_key[1],
84
+ session_id=session_id,
85
+ )
77
86
  if not owner:
78
- self._session_owners.set(session_id, identity)
87
+ await self._state_repository.set_owner(session_id=session_id, identity=identity)
79
88
  if self._inflight_session_creates.get(cache_key) is task:
80
89
  self._inflight_session_creates.pop(cache_key, None)
81
90
  return session_id, False
@@ -89,37 +98,45 @@ class SessionManager:
89
98
  ) -> None:
90
99
  await self.finalize_session_claim(identity=identity, session_id=session_id)
91
100
  async with self._lock:
92
- self._sessions.set((identity, context_id), session_id)
101
+ await self._state_repository.set_session(
102
+ identity=identity,
103
+ context_id=context_id,
104
+ session_id=session_id,
105
+ )
93
106
 
94
107
  async def claim_preferred_session(self, *, identity: str, session_id: str) -> bool:
95
108
  async with self._lock:
96
- owner = self._session_owners.get(session_id)
97
- pending_owner = self._pending_session_claims.get(session_id)
109
+ owner = await self._state_repository.get_owner(session_id=session_id)
110
+ pending_owner = await self._state_repository.get_pending_claim(session_id=session_id)
98
111
  if owner and owner != identity:
99
112
  raise PermissionError(f"Session {session_id} is not owned by you")
100
113
  if pending_owner and pending_owner != identity:
101
114
  raise PermissionError(f"Session {session_id} is not owned by you")
102
115
  if owner == identity:
103
116
  return False
104
- self._pending_session_claims[session_id] = identity
117
+ await self._state_repository.set_pending_claim(session_id=session_id, identity=identity)
105
118
  return True
106
119
 
107
120
  async def finalize_session_claim(self, *, identity: str, session_id: str) -> None:
108
121
  async with self._lock:
109
- owner = self._session_owners.get(session_id)
110
- pending_owner = self._pending_session_claims.get(session_id)
122
+ owner = await self._state_repository.get_owner(session_id=session_id)
123
+ pending_owner = await self._state_repository.get_pending_claim(session_id=session_id)
111
124
  if owner and owner != identity:
112
125
  raise PermissionError(f"Session {session_id} is not owned by you")
113
126
  if pending_owner and pending_owner != identity:
114
127
  raise PermissionError(f"Session {session_id} is not owned by you")
115
- self._session_owners.set(session_id, identity)
116
- if self._pending_session_claims.get(session_id) == identity:
117
- self._pending_session_claims.pop(session_id, None)
128
+ await self._state_repository.set_owner(session_id=session_id, identity=identity)
129
+ await self._state_repository.clear_pending_claim(
130
+ session_id=session_id,
131
+ identity=identity,
132
+ )
118
133
 
119
134
  async def release_preferred_session_claim(self, *, identity: str, session_id: str) -> None:
120
135
  async with self._lock:
121
- if self._pending_session_claims.get(session_id) == identity:
122
- self._pending_session_claims.pop(session_id, None)
136
+ await self._state_repository.clear_pending_claim(
137
+ session_id=session_id,
138
+ identity=identity,
139
+ )
123
140
 
124
141
  async def get_session_lock(self, session_id: str) -> asyncio.Lock:
125
142
  async with self._lock:
@@ -136,5 +153,5 @@ class SessionManager:
136
153
  context_id: str,
137
154
  ) -> asyncio.Task[str] | None:
138
155
  async with self._lock:
139
- self._sessions.pop((identity, context_id))
156
+ await self._state_repository.pop_session(identity=identity, context_id=context_id)
140
157
  return self._inflight_session_creates.pop((identity, context_id), None)
@@ -8,7 +8,7 @@ from typing import Any
8
8
  from a2a.types import TaskState
9
9
 
10
10
  from .stream_state import BlockType
11
- from .upstream_errors import (
11
+ from .upstream_error_translator import (
12
12
  _format_inband_upstream_error,
13
13
  _format_stream_terminal_error,
14
14
  _StreamTerminalSignal,
@@ -45,7 +45,7 @@ from .stream_state import (
45
45
  _StreamOutputState,
46
46
  _StreamPartState,
47
47
  )
48
- from .upstream_errors import _StreamTerminalSignal
48
+ from .upstream_error_translator import _StreamTerminalSignal
49
49
 
50
50
  logger = logging.getLogger("opencode_a2a.execution.executor")
51
51
 
@@ -429,7 +429,7 @@ class StreamRuntime:
429
429
  None,
430
430
  )
431
431
  if callable(remember_request):
432
- remember_request(
432
+ await remember_request(
433
433
  request_id=request_id,
434
434
  session_id=session_id,
435
435
  interrupt_type=asked["interrupt_type"],
@@ -456,7 +456,7 @@ class StreamRuntime:
456
456
  None,
457
457
  )
458
458
  if callable(discard_request):
459
- discard_request(resolved_request_id)
459
+ await discard_request(resolved_request_id)
460
460
  if cleared_pending:
461
461
  await _emit_interrupt_status(
462
462
  state=TaskState.working,
@@ -7,13 +7,13 @@ from dataclasses import dataclass
7
7
  import httpx
8
8
  from a2a.types import TaskState
9
9
 
10
- from ..error_taxonomy import (
10
+ from ..opencode_upstream_client import UpstreamContractError
11
+ from ..upstream_taxonomy import (
11
12
  extract_upstream_error_detail as _extract_upstream_error_detail,
12
13
  )
13
- from ..error_taxonomy import (
14
+ from ..upstream_taxonomy import (
14
15
  resolve_upstream_http_error_profile as _resolve_upstream_error_profile,
15
16
  )
16
- from ..opencode_upstream_client import UpstreamContractError
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -22,7 +22,7 @@ from ..contracts.extensions import (
22
22
  SESSION_QUERY_ERROR_BUSINESS_CODES,
23
23
  )
24
24
  from ..opencode_upstream_client import OpencodeUpstreamClient, UpstreamContractError
25
- from .error_mapping import (
25
+ from .error_responses import (
26
26
  interrupt_not_found_error,
27
27
  invalid_params_error,
28
28
  method_not_supported_error,
@@ -776,7 +776,7 @@ class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
776
776
  )
777
777
  resolve_request = getattr(self._upstream_client, "resolve_interrupt_request", None)
778
778
  if callable(resolve_request):
779
- status, binding = resolve_request(request_id)
779
+ status, binding = await resolve_request(request_id)
780
780
  if status != "active" or binding is None:
781
781
  return self._generate_error_response(
782
782
  base_request.id,
@@ -818,7 +818,7 @@ class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
818
818
  else:
819
819
  resolve_session = getattr(self._upstream_client, "resolve_interrupt_session", None)
820
820
  if callable(resolve_session):
821
- if not resolve_session(request_id):
821
+ if not await resolve_session(request_id):
822
822
  return self._generate_error_response(
823
823
  base_request.id,
824
824
  interrupt_not_found_error(
@@ -869,7 +869,7 @@ class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
869
869
  await self._upstream_client.question_reject(request_id, directory=directory)
870
870
  discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
871
871
  if callable(discard_request):
872
- discard_request(request_id)
872
+ await discard_request(request_id)
873
873
  except ValueError as exc:
874
874
  return self._generate_error_response(
875
875
  base_request.id,
@@ -880,7 +880,7 @@ class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
880
880
  if upstream_status == 404:
881
881
  discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
882
882
  if callable(discard_request):
883
- discard_request(request_id)
883
+ await discard_request(request_id)
884
884
  return self._generate_error_response(
885
885
  base_request.id,
886
886
  interrupt_not_found_error(
@@ -12,6 +12,8 @@ import httpx
12
12
 
13
13
  from .config import Settings
14
14
  from .parts.text import extract_text_from_parts
15
+ from .runtime_state import InterruptRequestBinding
16
+ from .server.state_store import InterruptRequestRepository, MemoryInterruptRequestRepository
15
17
 
16
18
  _UNSET = object()
17
19
  logger = logging.getLogger(__name__)
@@ -29,25 +31,13 @@ class OpencodeMessage:
29
31
  raw: dict[str, Any]
30
32
 
31
33
 
32
- @dataclass(frozen=True)
33
- class InterruptRequestBinding:
34
- request_id: str
35
- session_id: str
36
- interrupt_type: str
37
- identity: str | None
38
- task_id: str | None
39
- context_id: str | None
40
- expires_at: float
41
-
42
-
43
- @dataclass(frozen=True)
44
- class InterruptRequestTombstone:
45
- request_id: str
46
- expires_at: float
47
-
48
-
49
34
  class OpencodeUpstreamClient:
50
- def __init__(self, settings: Settings) -> None:
35
+ def __init__(
36
+ self,
37
+ settings: Settings,
38
+ *,
39
+ interrupt_request_repository: InterruptRequestRepository | None = None,
40
+ ) -> None:
51
41
  self._settings = settings
52
42
  self._base_url = settings.opencode_base_url.rstrip("/")
53
43
  self._directory = settings.opencode_workspace_root
@@ -61,10 +51,20 @@ class OpencodeUpstreamClient:
61
51
  settings.a2a_interrupt_request_tombstone_ttl_seconds
62
52
  )
63
53
  self._interrupt_request_clock = time.monotonic
64
- self._interrupt_requests: dict[str, InterruptRequestBinding] = {}
65
- self._interrupt_request_tombstones: dict[str, InterruptRequestTombstone] = {}
54
+ self._interrupt_request_repository = (
55
+ interrupt_request_repository
56
+ or MemoryInterruptRequestRepository(
57
+ request_ttl_seconds=self._interrupt_request_ttl_seconds,
58
+ tombstone_ttl_seconds=self._interrupt_request_tombstone_ttl_seconds,
59
+ clock=self._interrupt_request_clock,
60
+ )
61
+ )
66
62
  self._client = self._build_http_client(self._base_url)
67
63
 
64
+ def _sync_interrupt_clock(self) -> None:
65
+ if isinstance(self._interrupt_request_repository, MemoryInterruptRequestRepository):
66
+ self._interrupt_request_repository._clock = self._interrupt_request_clock
67
+
68
68
  def _build_http_client(self, base_url: str) -> httpx.AsyncClient:
69
69
  return httpx.AsyncClient(
70
70
  base_url=base_url,
@@ -157,36 +157,7 @@ class OpencodeUpstreamClient:
157
157
  )
158
158
  return self._require_boolean_response(endpoint=endpoint, payload=data)
159
159
 
160
- def _prune_interrupt_requests(self, *, now: float) -> None:
161
- expired = [
162
- request_id
163
- for request_id, binding in self._interrupt_requests.items()
164
- if binding.expires_at <= now
165
- ]
166
- for request_id in expired:
167
- self._interrupt_requests.pop(request_id, None)
168
- self._remember_interrupt_request_tombstone(request_id, now=now)
169
-
170
- def _prune_interrupt_request_tombstones(self, *, now: float) -> None:
171
- expired = [
172
- request_id
173
- for request_id, tombstone in self._interrupt_request_tombstones.items()
174
- if tombstone.expires_at <= now
175
- ]
176
- for request_id in expired:
177
- self._interrupt_request_tombstones.pop(request_id, None)
178
-
179
- def _remember_interrupt_request_tombstone(self, request_id: str, *, now: float) -> None:
180
- ttl = self._interrupt_request_tombstone_ttl_seconds
181
- if ttl <= 0:
182
- self._interrupt_request_tombstones.pop(request_id, None)
183
- return
184
- self._interrupt_request_tombstones[request_id] = InterruptRequestTombstone(
185
- request_id=request_id,
186
- expires_at=now + ttl,
187
- )
188
-
189
- def remember_interrupt_request(
160
+ async def remember_interrupt_request(
190
161
  self,
191
162
  *,
192
163
  request_id: str,
@@ -202,12 +173,8 @@ class OpencodeUpstreamClient:
202
173
  kind = interrupt_type.strip()
203
174
  if not request or not session or kind not in {"permission", "question"}:
204
175
  return
205
- now = self._interrupt_request_clock()
206
- self._prune_interrupt_requests(now=now)
207
- self._prune_interrupt_request_tombstones(now=now)
208
- ttl = self._interrupt_request_ttl_seconds if ttl_seconds is None else ttl_seconds
209
- expires_at = now + max(0.0, float(ttl))
210
- self._interrupt_requests[request] = InterruptRequestBinding(
176
+ self._sync_interrupt_clock()
177
+ await self._interrupt_request_repository.remember(
211
178
  request_id=request,
212
179
  session_id=session,
213
180
  interrupt_type=kind,
@@ -216,44 +183,30 @@ class OpencodeUpstreamClient:
216
183
  context_id=(
217
184
  context_id.strip() if isinstance(context_id, str) and context_id.strip() else None
218
185
  ),
219
- expires_at=expires_at,
186
+ ttl_seconds=ttl_seconds,
220
187
  )
221
- self._interrupt_request_tombstones.pop(request, None)
222
188
 
223
- def resolve_interrupt_request(
189
+ async def resolve_interrupt_request(
224
190
  self,
225
191
  request_id: str,
226
192
  ) -> tuple[str, InterruptRequestBinding | None]:
227
193
  request = request_id.strip()
228
194
  if not request:
229
195
  return "missing", None
230
- now = self._interrupt_request_clock()
231
- self._prune_interrupt_request_tombstones(now=now)
232
- binding = self._interrupt_requests.get(request)
233
- if binding is None:
234
- if request in self._interrupt_request_tombstones:
235
- return "expired", None
236
- return "missing", None
237
- if binding.expires_at <= now:
238
- self._interrupt_requests.pop(request, None)
239
- self._prune_interrupt_requests(now=now)
240
- self._remember_interrupt_request_tombstone(request, now=now)
241
- return "expired", None
242
- self._prune_interrupt_requests(now=now)
243
- return "active", binding
244
-
245
- def resolve_interrupt_session(self, request_id: str) -> str | None:
246
- status, binding = self.resolve_interrupt_request(request_id)
196
+ self._sync_interrupt_clock()
197
+ return await self._interrupt_request_repository.resolve(request_id=request)
198
+
199
+ async def resolve_interrupt_session(self, request_id: str) -> str | None:
200
+ status, binding = await self.resolve_interrupt_request(request_id)
247
201
  if status != "active" or binding is None:
248
202
  return None
249
203
  return binding.session_id
250
204
 
251
- def discard_interrupt_request(self, request_id: str) -> None:
205
+ async def discard_interrupt_request(self, request_id: str) -> None:
252
206
  request = request_id.strip()
253
207
  if not request:
254
208
  return
255
- self._interrupt_requests.pop(request, None)
256
- self._interrupt_request_tombstones.pop(request, None)
209
+ await self._interrupt_request_repository.discard(request_id=request)
257
210
 
258
211
  @property
259
212
  def stream_timeout(self) -> float | None:
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class InterruptRequestBinding:
8
+ request_id: str
9
+ session_id: str
10
+ interrupt_type: str
11
+ identity: str | None
12
+ task_id: str | None
13
+ context_id: str | None
14
+ expires_at: float
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class InterruptRequestTombstone:
19
+ request_id: str
20
+ expires_at: float