opencode-a2a 0.4.0__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 (136) hide show
  1. {opencode_a2a-0.4.0/src/opencode_a2a.egg-info → opencode_a2a-0.5.0}/PKG-INFO +4 -2
  2. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/README.md +1 -1
  3. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/docs/guide.md +36 -6
  4. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/pyproject.toml +2 -0
  5. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/cli.py +2 -10
  6. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/client/__init__.py +0 -4
  7. opencode_a2a-0.5.0/src/opencode_a2a/client/agent_card.py +81 -0
  8. opencode_a2a-0.5.0/src/opencode_a2a/client/client.py +317 -0
  9. opencode_a2a-0.5.0/src/opencode_a2a/client/error_mapping.py +148 -0
  10. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/client/errors.py +21 -0
  11. opencode_a2a-0.5.0/src/opencode_a2a/client/payload_text.py +180 -0
  12. opencode_a2a-0.5.0/src/opencode_a2a/client/request_context.py +86 -0
  13. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/config.py +47 -19
  14. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/contracts/extensions.py +5 -8
  15. opencode_a2a-0.5.0/src/opencode_a2a/execution/event_helpers.py +37 -0
  16. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/executor.py +88 -777
  17. opencode_a2a-0.5.0/src/opencode_a2a/execution/session_manager.py +157 -0
  18. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/stream_events.py +1 -1
  19. opencode_a2a-0.5.0/src/opencode_a2a/execution/stream_runtime.py +578 -0
  20. opencode_a2a-0.5.0/src/opencode_a2a/execution/tool_error_mapping.py +141 -0
  21. opencode_a2a-0.4.0/src/opencode_a2a/execution/upstream_errors.py → opencode_a2a-0.5.0/src/opencode_a2a/execution/upstream_error_translator.py +6 -82
  22. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/application.py +134 -245
  23. opencode_a2a-0.5.0/src/opencode_a2a/jsonrpc/error_responses.py +137 -0
  24. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/opencode_upstream_client.py +32 -79
  25. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/text.py +0 -4
  26. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/profile/runtime.py +7 -6
  27. opencode_a2a-0.5.0/src/opencode_a2a/runtime_state.py +20 -0
  28. opencode_a2a-0.5.0/src/opencode_a2a/sandbox_policy.py +111 -0
  29. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/server/application.py +198 -21
  30. opencode_a2a-0.5.0/src/opencode_a2a/server/state_store.py +664 -0
  31. opencode_a2a-0.5.0/src/opencode_a2a/server/task_store.py +116 -0
  32. opencode_a2a-0.5.0/src/opencode_a2a/upstream_taxonomy.py +95 -0
  33. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0/src/opencode_a2a.egg-info}/PKG-INFO +4 -2
  34. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/SOURCES.txt +25 -2
  35. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/requires.txt +2 -0
  36. opencode_a2a-0.5.0/tests/client/test_agent_card.py +68 -0
  37. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/client/test_client_facade.py +15 -299
  38. opencode_a2a-0.5.0/tests/client/test_error_mapping.py +81 -0
  39. opencode_a2a-0.5.0/tests/client/test_payload_text.py +104 -0
  40. opencode_a2a-0.5.0/tests/client/test_request_context.py +54 -0
  41. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/config/test_settings.py +49 -4
  42. opencode_a2a-0.5.0/tests/conftest.py +36 -0
  43. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/contracts/test_extension_contract_consistency.py +1 -1
  44. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_cancellation.py +23 -3
  45. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_directory_validation.py +20 -8
  46. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_metrics.py +2 -4
  47. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_multipart_input.py +3 -3
  48. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_opencode_agent_session_binding.py +150 -37
  49. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_session_ownership.py +167 -18
  50. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_core.py +41 -0
  51. opencode_a2a-0.5.0/tests/jsonrpc/test_error_responses.py +79 -0
  52. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_unsupported_method.py +35 -0
  53. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_commands.py +5 -2
  54. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_interrupts.py +7 -7
  55. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +8 -7
  56. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/parts/test_parts_text.py +2 -2
  57. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/profile/test_profile_runtime.py +17 -0
  58. opencode_a2a-0.5.0/tests/server/test_a2a_client_manager.py +199 -0
  59. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_agent_card.py +19 -0
  60. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_app_behaviors.py +1 -1
  61. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_cli.py +6 -15
  62. opencode_a2a-0.5.0/tests/server/test_database_app_persistence.py +210 -0
  63. opencode_a2a-0.5.0/tests/server/test_state_store.py +193 -0
  64. opencode_a2a-0.5.0/tests/server/test_task_store_factory.py +78 -0
  65. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_transport_contract.py +61 -35
  66. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/support/helpers.py +12 -7
  67. opencode_a2a-0.5.0/tests/support/session_extensions.py +19 -0
  68. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/support/streaming_output.py +3 -3
  69. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/upstream/test_opencode_upstream_client_params.py +54 -21
  70. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/uv.lock +113 -0
  71. opencode_a2a-0.4.0/src/opencode_a2a/client/client.py +0 -608
  72. opencode_a2a-0.4.0/src/opencode_a2a/client/types.py +0 -21
  73. opencode_a2a-0.4.0/tests/support/session_extensions.py +0 -10
  74. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.github/workflows/ci.yml +0 -0
  75. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.github/workflows/dependency-health.yml +0 -0
  76. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.github/workflows/publish.yml +0 -0
  77. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.gitignore +0 -0
  78. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.pre-commit-config.yaml +0 -0
  79. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/.secrets.baseline +0 -0
  80. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/AGENTS.md +0 -0
  81. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/CONTRIBUTING.md +0 -0
  82. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/LICENSE +0 -0
  83. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/SECURITY.md +0 -0
  84. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/README.md +0 -0
  85. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/check_coverage.py +0 -0
  86. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/dependency_health.sh +0 -0
  87. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/doctor.sh +0 -0
  88. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/health_common.sh +0 -0
  89. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/lint.sh +0 -0
  90. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/scripts/smoke_test_built_cli.sh +0 -0
  91. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/setup.cfg +0 -0
  92. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/__init__.py +0 -0
  93. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/client/config.py +0 -0
  94. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/contracts/__init__.py +0 -0
  95. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/__init__.py +0 -0
  96. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/request_context.py +0 -0
  97. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/execution/stream_state.py +0 -0
  98. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/__init__.py +0 -0
  99. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/methods.py +0 -0
  100. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/jsonrpc/params.py +0 -0
  101. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/__init__.py +0 -0
  102. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/parts/mapping.py +0 -0
  103. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/profile/__init__.py +0 -0
  104. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/server/__init__.py +0 -0
  105. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/server/agent_card.py +0 -0
  106. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/server/openapi.py +0 -0
  107. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a/server/request_parsing.py +0 -0
  108. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/dependency_links.txt +0 -0
  109. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/entry_points.txt +0 -0
  110. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/src/opencode_a2a.egg-info/top_level.txt +0 -0
  111. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/__init__.py +0 -0
  112. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/client/__init__.py +0 -0
  113. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/client/test_client_config.py +0 -0
  114. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/config/__init__.py +0 -0
  115. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/contracts/__init__.py +0 -0
  116. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/__init__.py +0 -0
  117. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_agent_errors.py +0 -0
  118. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_agent_helpers.py +0 -0
  119. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_blocks.py +0 -0
  120. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_interrupts.py +0 -0
  121. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/execution/test_streaming_output_contract_logging.py +0 -0
  122. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/__init__.py +0 -0
  123. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_methods.py +0 -0
  124. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_jsonrpc_params.py +0 -0
  125. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/jsonrpc/test_opencode_session_extension_queries.py +0 -0
  126. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/package/__init__.py +0 -0
  127. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/package/test_version.py +0 -0
  128. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/parts/__init__.py +0 -0
  129. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/profile/__init__.py +0 -0
  130. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/scripts/__init__.py +0 -0
  131. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/scripts/test_script_health_contract.py +0 -0
  132. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/__init__.py +0 -0
  133. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_call_context_builder.py +0 -0
  134. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/server/test_cancel_contract.py +0 -0
  135. {opencode_a2a-0.4.0 → opencode_a2a-0.5.0}/tests/support/__init__.py +0 -0
  136. {opencode_a2a-0.4.0 → 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.0
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
@@ -115,7 +117,7 @@ A2A_HOST=127.0.0.1 \
115
117
  A2A_PORT=8000 \
116
118
  A2A_PUBLIC_URL=http://127.0.0.1:8000 \
117
119
  OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
118
- opencode-a2a serve
120
+ opencode-a2a
119
121
  ```
120
122
 
121
123
  Verify that the service is up:
@@ -77,7 +77,7 @@ A2A_HOST=127.0.0.1 \
77
77
  A2A_PORT=8000 \
78
78
  A2A_PUBLIC_URL=http://127.0.0.1:8000 \
79
79
  OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
80
- opencode-a2a serve
80
+ opencode-a2a
81
81
  ```
82
82
 
83
83
  Verify that the service is up:
@@ -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:
@@ -154,7 +160,31 @@ A2A_HOST=127.0.0.1 \
154
160
  A2A_PORT=8000 \
155
161
  A2A_PUBLIC_URL=http://127.0.0.1:8000 \
156
162
  OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
157
- opencode-a2a serve
163
+ opencode-a2a
164
+ ```
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
158
188
  ```
159
189
 
160
190
  ## Troubleshooting Provider Auth State
@@ -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
  ]
@@ -53,7 +53,8 @@ def build_parser() -> argparse.ArgumentParser:
53
53
  parser = argparse.ArgumentParser(
54
54
  prog="opencode-a2a",
55
55
  description=(
56
- "OpenCode A2A runtime. Deployment supervision is intentionally left to the operator."
56
+ "OpenCode A2A runtime. Run without a subcommand to start the service."
57
+ " Deployment supervision is intentionally left to the operator."
57
58
  ),
58
59
  formatter_class=argparse.ArgumentDefaultsHelpFormatter,
59
60
  )
@@ -64,11 +65,6 @@ def build_parser() -> argparse.ArgumentParser:
64
65
  )
65
66
 
66
67
  subparsers = parser.add_subparsers(dest="command")
67
- subparsers.add_parser(
68
- "serve",
69
- help="Start the OpenCode A2A runtime using environment-based settings.",
70
- description="Start the OpenCode A2A runtime using environment-based settings.",
71
- )
72
68
 
73
69
  call_parser = subparsers.add_parser(
74
70
  "call",
@@ -95,10 +91,6 @@ def main(argv: Sequence[str] | None = None) -> int:
95
91
  return 0
96
92
 
97
93
  namespace = parser.parse_args(args)
98
- if namespace.command == "serve":
99
- serve_main()
100
- return 0
101
-
102
94
  if namespace.command == "call":
103
95
  return asyncio.run(run_call(namespace.agent_url, namespace.text, namespace.token))
104
96
 
@@ -10,7 +10,6 @@ from .errors import (
10
10
  A2AUnsupportedBindingError,
11
11
  A2AUnsupportedOperationError,
12
12
  )
13
- from .types import A2AClientEvent, A2AClientEventStream, A2AClientMetadata
14
13
 
15
14
  __all__ = [
16
15
  "A2AClient",
@@ -21,8 +20,5 @@ __all__ = [
21
20
  "A2AUnsupportedBindingError",
22
21
  "A2AUnsupportedOperationError",
23
22
  "A2AClientSettings",
24
- "A2AClientEvent",
25
- "A2AClientEventStream",
26
- "A2AClientMetadata",
27
23
  "load_settings",
28
24
  ]
@@ -0,0 +1,81 @@
1
+ """Helpers for agent-card URL normalization and resolver bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import urlsplit, urlunsplit
7
+
8
+ import httpx
9
+ from a2a.client.card_resolver import A2ACardResolver
10
+ from a2a.utils.constants import (
11
+ AGENT_CARD_WELL_KNOWN_PATH,
12
+ EXTENDED_AGENT_CARD_PATH,
13
+ PREV_AGENT_CARD_WELL_KNOWN_PATH,
14
+ )
15
+
16
+ from .request_context import build_default_headers
17
+
18
+
19
+ def normalize_agent_card_endpoint(agent_url: str) -> tuple[str, str]:
20
+ parsed_url = urlsplit(agent_url)
21
+ if not parsed_url.scheme or not parsed_url.netloc:
22
+ raise ValueError(f"agent_url must be absolute URL: {agent_url}")
23
+
24
+ path = parsed_url.path or ""
25
+ normalized_no_leading = path.rstrip("/").lstrip("/")
26
+ candidate_paths = (
27
+ AGENT_CARD_WELL_KNOWN_PATH,
28
+ PREV_AGENT_CARD_WELL_KNOWN_PATH,
29
+ EXTENDED_AGENT_CARD_PATH,
30
+ )
31
+
32
+ base_path = normalized_no_leading
33
+ agent_card_path = AGENT_CARD_WELL_KNOWN_PATH
34
+ for candidate_path in candidate_paths:
35
+ card_suffix = candidate_path.lstrip("/")
36
+ if normalized_no_leading.endswith(card_suffix):
37
+ base_path = normalized_no_leading[: -len(card_suffix)].rstrip("/")
38
+ agent_card_path = candidate_path
39
+ break
40
+
41
+ base_url = urlunsplit(
42
+ (
43
+ parsed_url.scheme,
44
+ parsed_url.netloc,
45
+ f"/{base_path}" if base_path else "",
46
+ "",
47
+ "",
48
+ )
49
+ ).rstrip("/")
50
+ return base_url, agent_card_path
51
+
52
+
53
+ def build_agent_card_resolver(
54
+ agent_url: str,
55
+ httpx_client: httpx.AsyncClient,
56
+ ) -> A2ACardResolver:
57
+ base_url, agent_card_path = normalize_agent_card_endpoint(agent_url)
58
+ return A2ACardResolver(
59
+ httpx_client=httpx_client,
60
+ base_url=base_url,
61
+ agent_card_path=agent_card_path,
62
+ )
63
+
64
+
65
+ def build_resolver_http_kwargs(
66
+ *,
67
+ bearer_token: str | None,
68
+ timeout: float,
69
+ ) -> dict[str, Any]:
70
+ http_kwargs: dict[str, Any] = {"timeout": timeout}
71
+ default_headers = build_default_headers(bearer_token)
72
+ if default_headers:
73
+ http_kwargs["headers"] = default_headers
74
+ return http_kwargs
75
+
76
+
77
+ __all__ = [
78
+ "build_agent_card_resolver",
79
+ "build_resolver_http_kwargs",
80
+ "normalize_agent_card_endpoint",
81
+ ]
@@ -0,0 +1,317 @@
1
+ """A2A client facade for opencode-a2a consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator, Mapping
7
+ from typing import Any, cast
8
+ from uuid import uuid4
9
+
10
+ import httpx
11
+ from a2a.client import Client, ClientConfig, ClientFactory
12
+ from a2a.client.errors import (
13
+ A2AClientHTTPError,
14
+ A2AClientJSONError,
15
+ A2AClientJSONRPCError,
16
+ )
17
+ from a2a.types import (
18
+ Message,
19
+ Part,
20
+ Role,
21
+ Task,
22
+ TaskArtifactUpdateEvent,
23
+ TaskIdParams,
24
+ TaskQueryParams,
25
+ TaskStatusUpdateEvent,
26
+ TextPart,
27
+ )
28
+
29
+ from .agent_card import build_agent_card_resolver, build_resolver_http_kwargs
30
+ from .config import A2AClientSettings, load_settings
31
+ from .error_mapping import (
32
+ map_agent_card_error,
33
+ map_operation_error,
34
+ )
35
+ from .errors import A2AUnsupportedBindingError
36
+ from .payload_text import extract_text as extract_text_from_payload
37
+ from .request_context import build_call_context, build_client_interceptors, split_request_metadata
38
+
39
+
40
+ class A2AClient:
41
+ """Factory-style facade for lightweight A2A client bootstrap and calls."""
42
+
43
+ def __init__(
44
+ self,
45
+ agent_url: str,
46
+ *,
47
+ settings: A2AClientSettings | None = None,
48
+ httpx_client: httpx.AsyncClient | None = None,
49
+ ) -> None:
50
+ if not agent_url or not agent_url.strip():
51
+ raise ValueError("agent_url must be non-empty")
52
+ self.agent_url = agent_url.rstrip("/")
53
+ self._settings = settings or load_settings({})
54
+ self._owns_httpx_client = httpx_client is None
55
+ self._httpx_client = httpx_client
56
+ self._client: Client | None = None
57
+ self._agent_card: object | None = None
58
+ self._lock = asyncio.Lock()
59
+ self._request_lock = asyncio.Lock()
60
+ self._active_requests = 0
61
+
62
+ async def close(self) -> None:
63
+ """Close cached client resources and owned HTTP transport."""
64
+ self._client = None
65
+ if self._httpx_client is not None and self._owns_httpx_client:
66
+ await self._httpx_client.aclose()
67
+
68
+ def is_busy(self) -> bool:
69
+ """Report whether this facade currently has in-flight work."""
70
+ return self._active_requests > 0
71
+
72
+ async def get_agent_card(self) -> Any:
73
+ """Fetch and cache peer Agent Card."""
74
+ if self._agent_card is not None:
75
+ return self._agent_card
76
+
77
+ resolver = build_agent_card_resolver(
78
+ self.agent_url,
79
+ await self._get_httpx_client(),
80
+ )
81
+ try:
82
+ card = await resolver.get_agent_card(
83
+ http_kwargs=build_resolver_http_kwargs(
84
+ bearer_token=self._settings.bearer_token,
85
+ timeout=self._settings.card_fetch_timeout,
86
+ )
87
+ )
88
+ except (
89
+ A2AClientHTTPError,
90
+ A2AClientJSONError,
91
+ httpx.TimeoutException,
92
+ httpx.TransportError,
93
+ ) as exc:
94
+ raise map_agent_card_error(exc) from exc
95
+ self._agent_card = card
96
+ return card
97
+
98
+ async def send_message(
99
+ self,
100
+ text: str,
101
+ *,
102
+ context_id: str | None = None,
103
+ task_id: str | None = None,
104
+ message_id: str | None = None,
105
+ metadata: Mapping[str, Any] | None = None,
106
+ extensions: list[str] | None = None,
107
+ ) -> AsyncIterator[
108
+ Message | tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None] | None
109
+ ]:
110
+ """Send one user message and stream protocol events."""
111
+ await self._acquire_operation()
112
+ try:
113
+ client = await self._ensure_client()
114
+ request_metadata, extra_headers = split_request_metadata(metadata)
115
+ request = self._build_user_message(
116
+ text=text,
117
+ context_id=context_id,
118
+ task_id=task_id,
119
+ message_id=message_id,
120
+ )
121
+ try:
122
+ async for event in client.send_message(
123
+ request,
124
+ context=build_call_context(self._settings.bearer_token, extra_headers),
125
+ request_metadata=request_metadata,
126
+ extensions=extensions,
127
+ ):
128
+ yield event
129
+ except (
130
+ A2AClientHTTPError,
131
+ A2AClientJSONRPCError,
132
+ httpx.TimeoutException,
133
+ httpx.TransportError,
134
+ ) as exc:
135
+ raise map_operation_error("message/send", exc) from exc
136
+ finally:
137
+ await self._release_operation()
138
+
139
+ async def send(
140
+ self,
141
+ text: str,
142
+ *,
143
+ context_id: str | None = None,
144
+ task_id: str | None = None,
145
+ message_id: str | None = None,
146
+ metadata: Mapping[str, Any] | None = None,
147
+ extensions: list[str] | None = None,
148
+ ) -> Message | tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None] | None:
149
+ """Send a message and return the terminal response/event."""
150
+ last_event: (
151
+ Message | tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None] | None
152
+ ) = None
153
+ async for event in self.send_message(
154
+ text,
155
+ context_id=context_id,
156
+ task_id=task_id,
157
+ message_id=message_id,
158
+ metadata=metadata,
159
+ extensions=extensions,
160
+ ):
161
+ last_event = event
162
+ return last_event
163
+
164
+ async def get_task(
165
+ self,
166
+ task_id: str,
167
+ *,
168
+ history_length: int | None = None,
169
+ metadata: Mapping[str, Any] | None = None,
170
+ ) -> Task:
171
+ """Fetch one task by id."""
172
+ await self._acquire_operation()
173
+ try:
174
+ client = await self._ensure_client()
175
+ request_metadata, extra_headers = split_request_metadata(metadata)
176
+ try:
177
+ return await client.get_task(
178
+ TaskQueryParams(
179
+ id=task_id,
180
+ history_length=history_length,
181
+ metadata=request_metadata or {},
182
+ ),
183
+ context=build_call_context(self._settings.bearer_token, extra_headers),
184
+ )
185
+ except (
186
+ A2AClientHTTPError,
187
+ A2AClientJSONRPCError,
188
+ httpx.TimeoutException,
189
+ httpx.TransportError,
190
+ ) as exc:
191
+ raise map_operation_error("tasks/get", exc) from exc
192
+ finally:
193
+ await self._release_operation()
194
+
195
+ async def cancel_task(
196
+ self,
197
+ task_id: str,
198
+ *,
199
+ metadata: Mapping[str, Any] | None = None,
200
+ ) -> Task:
201
+ """Cancel one task by id."""
202
+ await self._acquire_operation()
203
+ try:
204
+ client = await self._ensure_client()
205
+ request_metadata, extra_headers = split_request_metadata(metadata)
206
+ try:
207
+ return await client.cancel_task(
208
+ TaskIdParams(id=task_id, metadata=request_metadata or {}),
209
+ context=build_call_context(self._settings.bearer_token, extra_headers),
210
+ )
211
+ except (
212
+ A2AClientHTTPError,
213
+ A2AClientJSONRPCError,
214
+ httpx.TimeoutException,
215
+ httpx.TransportError,
216
+ ) as exc:
217
+ raise map_operation_error("tasks/cancel", exc) from exc
218
+ finally:
219
+ await self._release_operation()
220
+
221
+ async def resubscribe_task(
222
+ self,
223
+ task_id: str,
224
+ *,
225
+ metadata: Mapping[str, Any] | None = None,
226
+ ) -> AsyncIterator[tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None]]:
227
+ """Resubscribe to task updates."""
228
+ await self._acquire_operation()
229
+ try:
230
+ client = await self._ensure_client()
231
+ request_metadata, extra_headers = split_request_metadata(metadata)
232
+ try:
233
+ async for event in client.resubscribe(
234
+ TaskIdParams(id=task_id, metadata=request_metadata or {}),
235
+ context=build_call_context(self._settings.bearer_token, extra_headers),
236
+ ):
237
+ yield event
238
+ except (
239
+ A2AClientHTTPError,
240
+ A2AClientJSONRPCError,
241
+ httpx.TimeoutException,
242
+ httpx.TransportError,
243
+ ) as exc:
244
+ raise map_operation_error("tasks/resubscribe", exc) from exc
245
+ finally:
246
+ await self._release_operation()
247
+
248
+ async def _ensure_client(self) -> Client:
249
+ async with self._lock:
250
+ if self._client is not None:
251
+ return self._client
252
+ return await self._build_client()
253
+
254
+ async def _build_client(self) -> Client:
255
+ card = await self.get_agent_card()
256
+ config = ClientConfig(
257
+ streaming=True,
258
+ polling=False,
259
+ httpx_client=await self._get_httpx_client(),
260
+ supported_transports=list(self._settings.supported_transports),
261
+ use_client_preference=self._settings.use_client_preference,
262
+ )
263
+ try:
264
+ factory = ClientFactory(config, consumers=None)
265
+ client = factory.create(
266
+ card,
267
+ interceptors=build_client_interceptors(self._settings.bearer_token),
268
+ )
269
+ except ValueError as exc:
270
+ raise A2AUnsupportedBindingError(
271
+ f"No supported transport found for {self.agent_url}"
272
+ ) from exc
273
+ self._client = client
274
+ return client
275
+
276
+ async def _get_httpx_client(self) -> httpx.AsyncClient:
277
+ if self._httpx_client is not None:
278
+ return self._httpx_client
279
+ self._httpx_client = httpx.AsyncClient(timeout=self._settings.default_timeout)
280
+ return self._httpx_client
281
+
282
+ async def _acquire_operation(self) -> None:
283
+ async with self._request_lock:
284
+ self._active_requests += 1
285
+
286
+ async def _release_operation(self) -> None:
287
+ async with self._request_lock:
288
+ if self._active_requests > 0:
289
+ self._active_requests -= 1
290
+
291
+ def _build_user_message(
292
+ self,
293
+ *,
294
+ text: str,
295
+ context_id: str | None,
296
+ task_id: str | None,
297
+ message_id: str | None,
298
+ ) -> Message:
299
+ return Message(
300
+ role=Role.user,
301
+ message_id=message_id or str(uuid4()),
302
+ context_id=context_id,
303
+ task_id=task_id,
304
+ parts=self._normalize_parts(text),
305
+ metadata=None,
306
+ )
307
+
308
+ @classmethod
309
+ def extract_text(cls, payload: Any) -> str | None:
310
+ return extract_text_from_payload(payload)
311
+
312
+ # keep parts construction explicitly typed for mypy compatibility in older stubs
313
+ def _normalize_parts(self, text: str) -> list[Part]:
314
+ return [cast(Part, TextPart(text=text))]
315
+
316
+
317
+ __all__ = ["A2AClient"]