agentenv-framework 0.9.1254__py3-none-any.whl

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 (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
@@ -0,0 +1,900 @@
1
+ """Prompt a deployed agent task step."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import copy
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any, ClassVar, Optional
12
+ import httpx
13
+ from a2a.types import TaskState
14
+
15
+ from agent_env.a2a_agent import A2AAgent, conversation_store
16
+ from agent_env.a2a_agent import protocol
17
+ from agent_env.a2a_agent.object_transfer import (
18
+ TrajectoryUpload,
19
+ fetch_trajectory,
20
+ trajectory_mode,
21
+ )
22
+ from agent_env.config.model import MODEL_PARAMS_RESERVED
23
+ from agent_env.env.gateway.constants import EXT_CLOCK_URI, EXT_TRIGGERS_URI, TRIGGER_IN_FLIGHT_STATUSES
24
+ from agent_env.store import DuplicateKeyError, get_config
25
+ from agent_env.task_step.context import PromptResponse, TaskStepContext
26
+ from agent_env.entity_refs import EntityRef
27
+ from agent_env.task_step.task_step import RetryConfig, TaskStep, TaskStepDependency
28
+ from agent_env.task_step.task_steps.sandbox_utils.sandbox_utils import (
29
+ agent_error_text,
30
+ fetch_container_logs,
31
+ )
32
+ from agent_env.task_step.snapshot_utils.agent_state_capture import (
33
+ store_trajectory,
34
+ trajectory_object_url,
35
+ )
36
+ from agent_env.task_step.snapshot_utils.snapshot_series import SnapshotConfig, SnapshotSeries
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ def _parse_structured_output(text: str) -> Optional[dict]:
42
+ """Best-effort parse of the response text as a StructuredOutput JSON object (last embedded
43
+ object, or None for free text). Fallback for harnesses that don't surface the typed
44
+ ``structured_output`` on the A2A DataPart."""
45
+ if not text:
46
+ return None
47
+ s = text.strip()
48
+ if s.startswith("```"): # tolerate a ```json … ``` fence
49
+ s = s[3:]
50
+ if s[:4].lower() == "json":
51
+ s = s[4:]
52
+ if s.endswith("```"):
53
+ s = s[:-3]
54
+ s = s.strip()
55
+ # Fast path: the whole response is the JSON object.
56
+ if s.startswith("{"):
57
+ try:
58
+ parsed = json.loads(s)
59
+ if isinstance(parsed, dict):
60
+ return parsed
61
+ except ValueError:
62
+ pass
63
+ # Embedded in prose: scan top-level {...} objects and return the LAST — so an earlier
64
+ # JSON-looking snippet can't win over the real output (which agents emit last).
65
+ decoder = json.JSONDecoder()
66
+ last: Optional[dict] = None
67
+ i = s.find("{")
68
+ while i != -1:
69
+ try:
70
+ obj, end = decoder.raw_decode(s, i)
71
+ except ValueError:
72
+ i = s.find("{", i + 1)
73
+ continue
74
+ if isinstance(obj, dict):
75
+ last = obj
76
+ i = s.find("{", end)
77
+ return last
78
+
79
+
80
+ def _duplicates_prompt_text(parts: list[dict], prompt_text: Optional[str]) -> bool:
81
+ """True when ``parts`` is exactly the single text part already persisted as
82
+ ``PromptResponse.prompt_text`` — i.e. the first conversation turn of a
83
+ prompt-mode step. That text can be huge (a prompt can inline base64 images), so
84
+ storing it a second time in ``source_agent_per_turn_prompt_parts`` roughly
85
+ doubles the task-run context doc. Readers treat a ``None`` per-turn entry as "same as
86
+ ``prompt_text``"."""
87
+ if prompt_text is None:
88
+ return False
89
+ return parts == [{"kind": "text", "text": prompt_text}]
90
+
91
+
92
+ _DEFAULT_USER_SIM_OUTPUT_FORMAT: dict[str, Any] = {
93
+ "type": "json_schema",
94
+ "schema": {
95
+ "type": "object",
96
+ "properties": {
97
+ "message": {"type": "string", "description": "Your next reply"},
98
+ "done": {"type": "boolean", "description": "Set true when you're satisfied and the conversation should end."},
99
+ },
100
+ "required": ["message", "done"],
101
+ },
102
+ }
103
+
104
+
105
+ class PromptAgentTaskStep(TaskStep):
106
+ type: ClassVar[str] = "prompt_agent"
107
+ entity_refs = (EntityRef.env("snapshot_config.env_id"),)
108
+ DEFAULT_PROMPT_TIMEOUT_SECONDS: ClassVar[int] = 600
109
+ DEFAULT_POLL_INTERVAL_SECONDS: ClassVar[int] = 2
110
+
111
+ def __init__(
112
+ self,
113
+ id: str,
114
+ version: Optional[int],
115
+ prompt: Optional[str] = None,
116
+ parts: Optional[list[dict]] = None,
117
+ prompt_id: Optional[str] = None,
118
+ agent_name: Optional[str] = None,
119
+ timeout_seconds: int = DEFAULT_PROMPT_TIMEOUT_SECONDS,
120
+ trajectory_output_prefix: Optional[str] = None,
121
+ model: Optional[str] = None,
122
+ model_params: Optional[dict] = None,
123
+ system_prompt: Optional[str] = None,
124
+ max_turns: Optional[int] = None,
125
+ output_format: Optional[dict[str, Any]] = None,
126
+ max_thinking_tokens: Optional[int] = None,
127
+ effort: Optional[str] = None,
128
+ harness: Optional[str] = None,
129
+ agentenv_tools: Optional[list[str]] = None,
130
+ poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
131
+ context_id: Optional[str] = None,
132
+ max_conversation_turns: int = 1,
133
+ user_agent_name: str = "human_agent",
134
+ user_a2a_url: Optional[str] = None,
135
+ user_agent_timeout_seconds: int = 600,
136
+ user_output_format: Optional[dict[str, Any]] = None,
137
+ user_model: Optional[str] = None,
138
+ depends_on: Optional[list[TaskStepDependency]] = None,
139
+ fail_task_on_error: bool = True,
140
+ retry_config: Optional[RetryConfig | dict] = None,
141
+ *,
142
+ snapshot_config: Optional[SnapshotConfig] = None,
143
+ ):
144
+ # `prompt` is the legacy text-only field; `parts` is the multimodal
145
+ # form that's the authoritative A2A wire shape. Exactly one must be set:
146
+ # callers pre-parts pass `prompt="..."`; multimodal callers pass
147
+ # `parts=[{"kind":"text",...}, {"kind":"file","file":{...}}, ...]`.
148
+ if prompt is None and parts is None:
149
+ raise ValueError("PromptAgentTaskStep requires either `prompt` or `parts`")
150
+ if prompt is not None and parts is not None:
151
+ raise ValueError("PromptAgentTaskStep accepts `prompt` OR `parts`, not both")
152
+ if parts is not None:
153
+ self._validate_parts(parts)
154
+ if max_conversation_turns < 1:
155
+ raise ValueError("max_conversation_turns must be >= 1")
156
+ if model_params:
157
+ reserved = set(model_params) & MODEL_PARAMS_RESERVED
158
+ if reserved:
159
+ raise ValueError(f"model_params may not set reserved keys {sorted(reserved)} (agent-env/the harness set these)")
160
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error, retry_config=retry_config)
161
+ self.prompt_id = prompt_id or uuid.uuid4().hex
162
+ self.prompt = prompt
163
+ self.parts = parts
164
+ self.agent_name = agent_name or TaskStep.DEFAULT_AGENT_NAME
165
+ self.timeout_seconds = timeout_seconds
166
+ self.trajectory_output_prefix = trajectory_output_prefix
167
+ self.model = model
168
+ self.model_params = model_params
169
+ self.system_prompt = system_prompt
170
+ self.max_turns = max_turns
171
+ self.output_format = output_format
172
+ self.max_thinking_tokens = max_thinking_tokens
173
+ self.effort = effort
174
+ self.harness = harness
175
+ self.agentenv_tools = agentenv_tools
176
+ self.poll_interval_seconds = poll_interval_seconds
177
+ self.context_id = context_id
178
+ self.max_conversation_turns = max_conversation_turns
179
+ self.user_agent_name = user_agent_name
180
+ self.user_a2a_url = user_a2a_url
181
+ self.user_agent_timeout_seconds = user_agent_timeout_seconds
182
+ self.user_output_format = user_output_format
183
+ self.user_model = user_model
184
+ self.snapshot_config = snapshot_config
185
+
186
+ @staticmethod
187
+ def _validate_parts(parts: list[dict]) -> None:
188
+ """Reject malformed parts at construction. Each entry must conform to
189
+ the A2A `Part` discriminated union (text/file/data) per a2a-sdk 0.3.26."""
190
+ from a2a.types import Part
191
+ if not isinstance(parts, list):
192
+ raise ValueError(f"`parts` must be a list, got {type(parts).__name__}")
193
+ for i, p in enumerate(parts):
194
+ if not isinstance(p, dict):
195
+ raise ValueError(f"parts[{i}] must be a dict, got {type(p).__name__}")
196
+ try:
197
+ Part.model_validate(p)
198
+ except Exception as e:
199
+ raise ValueError(f"parts[{i}] is not a valid A2A Part: {e}") from e
200
+
201
+ def _effective_parts(self) -> list[dict]:
202
+ """Return the A2A wire-form parts list. If `parts` was set explicitly,
203
+ return it; otherwise materialize a singleton text part from the legacy
204
+ `prompt` field. Callers should treat the result as read-only and use
205
+ `_apply_seed` to get a substituted copy before sending."""
206
+ if self.parts is not None:
207
+ return self.parts
208
+ return [{"kind": "text", "text": self.prompt}]
209
+
210
+ def _apply_seed(self, parts: list[dict], seed: dict) -> list[dict]:
211
+ """Return a deep-copied parts list with seed substitutions applied.
212
+ `<key>` placeholders are replaced inside text parts' `text` and inside
213
+ file parts' `uri`/`name` (so tasks-as-templates with
214
+ `s3://bucket/seeds/<seed_id>/x.png` resolve per-run)."""
215
+ if not seed:
216
+ return parts
217
+ out = copy.deepcopy(parts)
218
+ for p in out:
219
+ kind = p.get("kind")
220
+ if kind == "text":
221
+ for k, v in seed.items():
222
+ p["text"] = p["text"].replace(f"<{k}>", str(v))
223
+ elif kind == "file":
224
+ f = p.get("file") or {}
225
+ for field in ("uri", "name"):
226
+ if field in f and isinstance(f[field], str):
227
+ for k, v in seed.items():
228
+ f[field] = f[field].replace(f"<{k}>", str(v))
229
+ return out
230
+
231
+ def to_dict(self) -> dict:
232
+ base = super().to_dict()
233
+ base["prompt_id"] = self.prompt_id
234
+ base["prompt"] = self.prompt
235
+ base["parts"] = self.parts
236
+ base["agent_name"] = self.agent_name
237
+ base["timeout_seconds"] = self.timeout_seconds
238
+ base["trajectory_output_prefix"] = self.trajectory_output_prefix
239
+ base["model"] = self.model
240
+ base["model_params"] = self.model_params
241
+ base["system_prompt"] = self.system_prompt
242
+ base["max_turns"] = self.max_turns
243
+ base["output_format"] = self.output_format
244
+ base["max_thinking_tokens"] = self.max_thinking_tokens
245
+ base["effort"] = self.effort
246
+ base["harness"] = self.harness
247
+ base["agentenv_tools"] = self.agentenv_tools
248
+ base["poll_interval_seconds"] = self.poll_interval_seconds
249
+ base["context_id"] = self.context_id
250
+ base["max_conversation_turns"] = self.max_conversation_turns
251
+ base["user_agent_name"] = self.user_agent_name
252
+ base["user_a2a_url"] = self.user_a2a_url
253
+ base["user_agent_timeout_seconds"] = self.user_agent_timeout_seconds
254
+ base["user_output_format"] = self.user_output_format
255
+ base["user_model"] = self.user_model
256
+ if self.snapshot_config:
257
+ base["snapshot_config"] = self.snapshot_config.to_dict()
258
+ return base
259
+
260
+ @classmethod
261
+ def from_dict(cls, data: dict) -> PromptAgentTaskStep:
262
+ return cls(
263
+ **cls._base_from_dict(data),
264
+ retry_config=data.get("retry_config"),
265
+ prompt=data.get("prompt"),
266
+ parts=data.get("parts"),
267
+ prompt_id=data.get("prompt_id"),
268
+ agent_name=data.get("agent_name"),
269
+ timeout_seconds=data.get("timeout_seconds", cls.DEFAULT_PROMPT_TIMEOUT_SECONDS),
270
+ trajectory_output_prefix=data.get("trajectory_output_prefix"),
271
+ model=data.get("model"),
272
+ model_params=data.get("model_params"),
273
+ system_prompt=data.get("system_prompt"),
274
+ max_turns=data.get("max_turns"),
275
+ output_format=data.get("output_format"),
276
+ max_thinking_tokens=data.get("max_thinking_tokens"),
277
+ effort=data.get("effort"),
278
+ harness=data.get("harness"),
279
+ agentenv_tools=data.get("agentenv_tools"),
280
+ poll_interval_seconds=data.get("poll_interval_seconds", cls.DEFAULT_POLL_INTERVAL_SECONDS),
281
+ context_id=data.get("context_id"),
282
+ max_conversation_turns=data.get("max_conversation_turns", 1),
283
+ user_agent_name=data.get("user_agent_name", "human_agent"),
284
+ user_a2a_url=data.get("user_a2a_url"),
285
+ user_agent_timeout_seconds=data.get("user_agent_timeout_seconds", 600),
286
+ user_output_format=data.get("user_output_format"),
287
+ user_model=data.get("user_model"),
288
+ snapshot_config=(
289
+ SnapshotConfig.from_dict(data["snapshot_config"])
290
+ if data.get("snapshot_config")
291
+ else None
292
+ ),
293
+ )
294
+
295
+ def _trajectory_prefix(self) -> str:
296
+ if self.trajectory_output_prefix:
297
+ return self.trajectory_output_prefix
298
+ config = get_config()
299
+ return config.get_object_store().object_url(
300
+ f"{config.get_artifact_key_prefix()}prompt_agent_trajectories/prompt_id={self.prompt_id}/"
301
+ )
302
+
303
+ def _warn_on_snapshot_budget(self) -> None:
304
+ """Flag snapshot settings that will not capture what the caller asked for.
305
+
306
+ Both cadences spend one shared budget, so a conversation longer than
307
+ ``max_snapshots`` silently stops being captured partway through — the run
308
+ still succeeds, and only the tail of the curve is missing. Warned at startup
309
+ because by the time the rows are graded the run is over.
310
+ """
311
+ cfg = self.snapshot_config
312
+ if not cfg or not cfg.per_turn:
313
+ return
314
+ if self.max_conversation_turns < 2:
315
+ logger.warning(
316
+ f"{self.id}: per_turn snapshotting is enabled but "
317
+ f"max_conversation_turns={self.max_conversation_turns} (<2); there is "
318
+ "no turn boundary to capture at, so only the final capture runs"
319
+ )
320
+ return
321
+ # One boundary per turn except the last, which teardown's final capture covers.
322
+ boundaries = self.max_conversation_turns - 1
323
+ if cfg.ticks:
324
+ logger.info(
325
+ f"{self.id}: per_turn and interval_seconds={cfg.interval_seconds} share "
326
+ f"the {cfg.max_snapshots}-capture budget, so ticks can consume slots "
327
+ f"the {boundaries} turn boundaries would have used"
328
+ )
329
+ if boundaries > cfg.max_snapshots:
330
+ logger.warning(
331
+ f"{self.id}: max_snapshots={cfg.max_snapshots} is below the "
332
+ f"{boundaries} turn boundaries in this conversation; captures stop "
333
+ f"after turn {cfg.max_snapshots} and the rest are recorded as skipped"
334
+ )
335
+
336
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
337
+ # Minted here, not in `_execute_conversation`, so the series can address the
338
+ # same session before the first turn.
339
+ a2a_context_id = self.context_id or uuid.uuid4().hex
340
+ if not self.snapshot_config:
341
+ return await self._execute_conversation(context, a2a_context_id=a2a_context_id)
342
+ series = SnapshotSeries(
343
+ step_id=self.id,
344
+ agent_name=self.agent_name,
345
+ prompt_id=self.prompt_id,
346
+ a2a_context_id=a2a_context_id,
347
+ config=self.snapshot_config,
348
+ trajectory_output_prefix=self._trajectory_prefix(),
349
+ instance_id=context.instance_id,
350
+ )
351
+ self._warn_on_snapshot_budget()
352
+ series.start(context)
353
+ try:
354
+ result = await self._execute_conversation(
355
+ context, a2a_context_id=a2a_context_id, series=series
356
+ )
357
+ finally:
358
+ # On failure too: a run that died is still a point worth grading.
359
+ await series.finish(context)
360
+ # After the finally, so it cannot replace the agent's own exception.
361
+ series.raise_if_final_capture_missing()
362
+ return result
363
+
364
+ async def _execute_conversation(
365
+ self,
366
+ context: TaskStepContext,
367
+ *,
368
+ a2a_context_id: str,
369
+ series: Optional[SnapshotSeries] = None,
370
+ ) -> TaskStepContext:
371
+ agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
372
+ if agent is None:
373
+ raise RuntimeError(f"Agent with name '{self.agent_name}' not found in context.deployed_agents")
374
+ target_url = agent.a2a_url or agent.api_url
375
+ user_sim = next((a for a in context.deployed_agents if a.agent_name == self.user_agent_name), None)
376
+ user_url = self.user_a2a_url or (
377
+ (user_sim.a2a_url or user_sim.api_url) if user_sim else None
378
+ )
379
+ if user_url is None and user_sim is None and self.max_conversation_turns > 1:
380
+ user_url = get_config().get_default_human_a2a_url()
381
+
382
+ trajectory_output_prefix = self._trajectory_prefix()
383
+
384
+ system_prompt = self.system_prompt
385
+ seed = context.metadata.get("seed", {})
386
+ if seed and system_prompt:
387
+ for key, value in seed.items():
388
+ system_prompt = system_prompt.replace(f"<{key}>", str(value))
389
+ initial_parts = self._apply_seed(self._effective_parts(), seed)
390
+ prompt_text = initial_parts[0]["text"] if self.prompt is not None else None
391
+
392
+ model = context.agent_model or self.model or context.default_agent_model
393
+ harness = context.agent_harness or self.harness
394
+ overrides = context.metadata.get("user_overrides", {})
395
+ effort = overrides.get("agent_effort") or self.effort
396
+ max_thinking_tokens = overrides.get("agent_max_thinking_tokens") or self.max_thinking_tokens
397
+ max_turns = overrides.get("agent_max_turns") or self.max_turns
398
+
399
+ card = agent.a2a_card or {}
400
+ desired_config = {
401
+ "model": model, "system_prompt": system_prompt, "effort": effort,
402
+ "harness": harness, "max_turns": max_turns,
403
+ "max_thinking_tokens": max_thinking_tokens,
404
+ "output_format": self.output_format, "timeout_seconds": self.timeout_seconds,
405
+ "project_id": context.metadata.get("project_id"),
406
+ "task_id": context.metadata.get("task_id"),
407
+ "agentenv_tools": self.agentenv_tools,
408
+ }
409
+ desired_config = {k: v for k, v in desired_config.items() if v is not None}
410
+ model_params = get_config().get_model_params(self.model_params)
411
+ if model_params:
412
+ desired_config["model_params"] = model_params
413
+ negotiated = A2AAgent.negotiate_agent_config(card, desired_config)
414
+ if negotiated:
415
+ await protocol.post_agent_config(target_url + negotiated.endpoint, negotiated.fields)
416
+
417
+ is_user_sim = user_sim is not None and self.user_a2a_url is None
418
+ if is_user_sim:
419
+ user_output_format = self.user_output_format or _DEFAULT_USER_SIM_OUTPUT_FORMAT
420
+ user_config = {k: v for k, v in {"output_format": user_output_format, "model": self.user_model}.items() if v is not None}
421
+ negotiated = A2AAgent.negotiate_agent_config(user_sim.a2a_card or {}, user_config)
422
+ if negotiated:
423
+ await protocol.post_agent_config(user_url + negotiated.endpoint, negotiated.fields)
424
+ logger.info(f"Configured user-sim '{self.user_agent_name}' with structured output_format for done-signal")
425
+
426
+ triggers_registered = is_user_sim and any(
427
+ r.get("agent_name") == self.user_agent_name
428
+ for r in context.metadata.get("agent_trigger_registrations", [])
429
+ )
430
+ trig_ext = A2AAgent.find_extension(user_sim.a2a_card or {}, A2AAgent.EXT_TRIGGERS) if is_user_sim else None
431
+ decide_url = state_url = None
432
+ if trig_ext and triggers_registered:
433
+ decide_method = A2AAgent.extension_method(user_sim.a2a_card, A2AAgent.EXT_TRIGGERS, "decide") or {}
434
+ decide_url = user_url + decide_method.get("endpoint", "/ext/triggers/decide")
435
+ state_url = user_url + (trig_ext.get("params") or {}).get("endpoint", "/ext/triggers")
436
+ if self.max_conversation_turns < 2:
437
+ logger.warning(
438
+ f"user agent '{self.user_agent_name}' advertises {A2AAgent.EXT_TRIGGERS} but "
439
+ f"max_conversation_turns={self.max_conversation_turns} (<2); the trigger engine is never invoked")
440
+
441
+ solver_context_id = a2a_context_id
442
+ if self.context_id:
443
+ run_component = context.instance_id or uuid.uuid4().hex
444
+ conversation_id = f"{self.context_id}-{run_component}-{len(run_component)}"
445
+ else:
446
+ conversation_id = solver_context_id
447
+ try:
448
+ conversation_store.create_conversation(
449
+ conversation_id=conversation_id,
450
+ task_instance_id=context.instance_id or "",
451
+ source_agent_name=self.user_agent_name,
452
+ target_agent_name=self.agent_name,
453
+ )
454
+ except DuplicateKeyError:
455
+ # reuse a pre-existing conversation on retry/resume (store raises agent_env.store.DuplicateKeyError, not pymongo's)
456
+ logger.info(f"Reusing existing conversation {conversation_id} on re-run")
457
+ context.metadata.setdefault("a2a_conversations", {})[self.id] = conversation_id
458
+ logger.info(
459
+ f"Started multi-turn conversation {conversation_id} (max {self.max_conversation_turns} turns) "
460
+ f"between {self.user_agent_name} and {self.agent_name}"
461
+ )
462
+
463
+ current_user_parts = initial_parts
464
+ final_terminal = protocol.TerminalResponse(response_text="")
465
+ final_task_id = ""
466
+ per_turn_trajectory_s3_uris: list[str | None] = []
467
+ source_agent_per_turn_prompt_parts: list[list[dict] | None] = []
468
+ traj_ext_cached = A2AAgent.find_extension(card, A2AAgent.EXT_TRAJECTORY)
469
+ final_state: str = TaskState.completed.value
470
+ trajectory_s3_uri: Optional[str] = None
471
+
472
+ for turn in range(self.max_conversation_turns):
473
+ # `target_a2a_task_id` is the client A2A message id sent to the target
474
+ # agent and recorded on the conversation as `a2a_task_id`.
475
+ # This id is also used as part of the key name for the trajectory S3 object.
476
+ target_a2a_task_id = uuid.uuid4().hex
477
+ conversation_store.add_a2a_task(
478
+ conversation_id=conversation_id,
479
+ parts=current_user_parts,
480
+ a2a_task_id=target_a2a_task_id,
481
+ role="user",
482
+ )
483
+ # Turn 0 of a prompt-mode step re-sends exactly the text already
484
+ # persisted as PromptResponse.prompt_text; store a None placeholder
485
+ # instead of a second copy so the doc doesn't carry the prompt twice.
486
+ # The list stays index-aligned with the per-turn trajectory URIs.
487
+ source_agent_per_turn_prompt_parts.append(
488
+ None
489
+ if turn == 0 and _duplicates_prompt_text(current_user_parts, prompt_text)
490
+ else list(current_user_parts)
491
+ )
492
+
493
+ sent_task_id, _ = await protocol.send_a2a_message(
494
+ target_url, current_user_parts, target_a2a_task_id,
495
+ solver_context_id, self.timeout_seconds,
496
+ )
497
+ result = await protocol.poll_a2a_task(
498
+ target_url, sent_task_id, self.timeout_seconds, self.poll_interval_seconds,
499
+ )
500
+ target_state = result["status"]["state"]
501
+ status_msg = (result.get("status") or {}).get("message") or {}
502
+ final_terminal = protocol.TerminalResponse.from_message(status_msg)
503
+ agent_response_parts = status_msg.get("parts") or [{"kind": "text", "text": final_terminal.response_text}]
504
+
505
+ conversation_store.complete_a2a_task(
506
+ conversation_id=conversation_id,
507
+ parts=agent_response_parts,
508
+ role="agent",
509
+ )
510
+
511
+ if traj_ext_cached:
512
+ turn_traj_uri: str | None = None
513
+ try:
514
+ turn_traj_uri = await self._fetch_trajectory(
515
+ target_url, traj_ext_cached, sent_task_id, trajectory_output_prefix,
516
+ target_a2a_task_id,
517
+ )
518
+ except Exception as e:
519
+ logger.warning(f"Turn {turn+1} trajectory fetch failed (continuing): {e}")
520
+ per_turn_trajectory_s3_uris.append(turn_traj_uri)
521
+
522
+ final_task_id = sent_task_id
523
+ final_state = target_state
524
+ logger.info(f"Turn {turn+1}/{self.max_conversation_turns} agent response ({target_state}): {final_terminal.response_text[:120]}...")
525
+
526
+ if target_state == TaskState.failed:
527
+ conversation_store.mark_closed(conversation_id)
528
+ break
529
+ if turn == self.max_conversation_turns - 1:
530
+ break
531
+
532
+ conv_doc = conversation_store.get_conversation(conversation_id)
533
+ if conv_doc and conv_doc.get("status") != "active":
534
+ logger.info(f"Conversation {conversation_id} no longer active (status={conv_doc.get('status')}); ending multi-turn")
535
+ break
536
+
537
+ # After every `break` above: a turn that ends the conversation is covered
538
+ # by teardown's final capture instead of being captured twice.
539
+ if series is not None:
540
+ await series.capture_turn(context, turn + 1)
541
+
542
+ if trig_ext and triggers_registered:
543
+ env_triggers = await self._read_env_triggers(context)
544
+ (context.metadata.setdefault("env_trigger_snapshots", {})
545
+ .setdefault(self.id, []).append({"turn": turn + 1, "envs": env_triggers}))
546
+ resp = await self._decide(decide_url, turn + 1, final_terminal.response_text, conversation_id, env_triggers)
547
+ (context.metadata.setdefault("agent_trigger_firings", {})
548
+ .setdefault(self.id, []).append({"turn": turn + 1, "fired": resp.get("fired", [])}))
549
+ logger.info(f"Turn {turn+1} agent-trigger decide: fired={resp.get('fired', [])} done={resp.get('done')}")
550
+ if bool(resp.get("done")):
551
+ current_user_parts = resp.get("parts") or []
552
+ conversation_store.add_a2a_task(conversation_id, parts=current_user_parts,
553
+ a2a_task_id=uuid.uuid4().hex, role="user")
554
+ conversation_store.mark_closed(conversation_id)
555
+ break
556
+ if resp.get("fired"):
557
+ current_user_parts = resp.get("parts") or []
558
+ continue
559
+ # no trigger fired -> fall through to the LLM user-sim for this turn (hybrid)
560
+
561
+ user_a2a_task_id = uuid.uuid4().hex
562
+ try:
563
+ sent_user_task_id, _ = await protocol.send_a2a_message(
564
+ user_url, agent_response_parts, user_a2a_task_id,
565
+ conversation_id, self.user_agent_timeout_seconds,
566
+ )
567
+ user_result = await protocol.poll_a2a_task(
568
+ user_url, sent_user_task_id,
569
+ self.user_agent_timeout_seconds, self.poll_interval_seconds,
570
+ )
571
+ except TimeoutError:
572
+ logger.warning(f"user_a2a_url timeout for conversation {conversation_id}; marking abandoned")
573
+ conversation_store.mark_closed(conversation_id)
574
+ break
575
+
576
+ user_state = (user_result.get("status") or {}).get("state")
577
+ if user_state != TaskState.completed:
578
+ logger.info(f"user_a2a_url returned state={user_state}; ending multi-turn")
579
+ conversation_store.mark_closed(conversation_id)
580
+ break
581
+
582
+ user_msg = (user_result.get("status") or {}).get("message") or {}
583
+ current_user_parts = user_msg.get("parts") or []
584
+ if not current_user_parts:
585
+ logger.warning(f"user_a2a_url returned no parts; ending multi-turn")
586
+ conversation_store.mark_closed(conversation_id)
587
+ break
588
+
589
+ if is_user_sim:
590
+ raw_text = next((p["text"] for p in current_user_parts if p.get("kind") == "text"), "")
591
+ try:
592
+ parsed = json.loads(raw_text)
593
+ message_text = parsed["message"]
594
+ done = bool(parsed["done"])
595
+ except (json.JSONDecodeError, KeyError) as e:
596
+ logger.warning(f"user-sim returned malformed output despite output_format ({e}); using raw text as next prompt; raw={raw_text[:200]!r}")
597
+ current_user_parts = [{"kind": "text", "text": raw_text}]
598
+ else:
599
+ extras = {k: v for k, v in parsed.items() if k not in ("message", "done")}
600
+ if extras:
601
+ (context.metadata.setdefault("usersim_turn_outputs", {})
602
+ .setdefault(self.id, []).append({"turn": turn + 1, "fields": extras}))
603
+ current_user_parts = [{"kind": "text", "text": message_text}]
604
+ if done:
605
+ logger.info(f"user-sim signaled done=true at turn {turn+1}; ending multi-turn")
606
+ conversation_store.mark_closed(conversation_id)
607
+ break
608
+
609
+ await self._persist_env_trigger_state(context)
610
+
611
+ final_conv = conversation_store.get_conversation(conversation_id)
612
+ if final_conv and final_conv.get("status") == "active":
613
+ conversation_store.mark_closed(conversation_id)
614
+
615
+ if trig_ext and triggers_registered: # best-effort firing-log readback (provenance only; never fails the run)
616
+ try:
617
+ async with httpx.AsyncClient() as client:
618
+ r = await client.get(state_url, timeout=15)
619
+ r.raise_for_status()
620
+ context.metadata.setdefault("agent_trigger_state", {})[self.id] = r.json().get("firing_log", [])
621
+ except Exception as e:
622
+ logger.warning(f"agent-trigger state readback failed (continuing): {e}")
623
+
624
+ trajectory_s3_uri = next(
625
+ (uri for uri in reversed(per_turn_trajectory_s3_uris) if uri is not None),
626
+ None,
627
+ )
628
+
629
+ container_logs: Optional[str] = None
630
+ if final_state == TaskState.failed:
631
+ logger.error(
632
+ "A2A task %s failed (agent=%s sandbox=%s error_type=%s exception=%s). "
633
+ "Raw A2A status message: %s",
634
+ final_task_id, self.agent_name, getattr(agent, "sandbox_id", None),
635
+ final_terminal.error_type, final_terminal.error_class, json.dumps(status_msg, default=str)[:4000],
636
+ )
637
+ container_logs = await fetch_container_logs(agent)
638
+ if container_logs:
639
+ logger.error(
640
+ "Agent container logs for failed A2A task %s (agent=%s):\n%s",
641
+ final_task_id, self.agent_name, container_logs,
642
+ )
643
+
644
+ final_error_for_response: Optional[str] = None
645
+ if final_state == TaskState.failed:
646
+ final_error_for_response = (
647
+ agent_error_text(final_terminal.error_message, final_terminal.response_text)
648
+ or container_logs
649
+ or None
650
+ )
651
+
652
+ # Prefer the agent's typed structured output; fall back to parsing the response text.
653
+ structured_output = (
654
+ final_terminal.structured_output
655
+ if final_terminal.structured_output is not None
656
+ else _parse_structured_output(final_terminal.response_text)
657
+ )
658
+
659
+ context.prompt_responses.append(PromptResponse(
660
+ prompt_id=self.prompt_id,
661
+ response=final_terminal.response_text,
662
+ prompt_text=prompt_text,
663
+ agent_trajectory_s3_uri=trajectory_s3_uri,
664
+ agent_trajectory_s3_prefix=trajectory_output_prefix,
665
+ target_agent_per_turn_trajectory_s3_uris=per_turn_trajectory_s3_uris or None,
666
+ source_agent_per_turn_prompt_parts=source_agent_per_turn_prompt_parts or None,
667
+ tool_call_count=final_terminal.tool_call_count,
668
+ model=model,
669
+ error_type=final_terminal.error_type,
670
+ error_code=final_terminal.error_code,
671
+ error_message=final_error_for_response,
672
+ agent_session_id=final_task_id,
673
+ a2a_context_id=solver_context_id,
674
+ agent_name=self.agent_name,
675
+ step_id=self.id,
676
+ structured_output=structured_output,
677
+ ))
678
+
679
+ if final_state == TaskState.failed:
680
+ detail_parts = []
681
+ if final_terminal.error_type:
682
+ detail_parts.append(f"error_type={final_terminal.error_type}")
683
+ if final_terminal.error_code:
684
+ detail_parts.append(f"error_code={final_terminal.error_code}")
685
+ if final_terminal.error_class:
686
+ detail_parts.append(f"exception={final_terminal.error_class}")
687
+ detail_parts.append(f"task_id={final_task_id}")
688
+ sandbox_id = getattr(agent, "sandbox_id", None)
689
+ if sandbox_id:
690
+ detail_parts.append(f"agent_sandbox={sandbox_id}")
691
+ detail = ", ".join(detail_parts)
692
+ body = agent_error_text(final_terminal.error_message, final_terminal.response_text)
693
+ if container_logs:
694
+ body = f"{body}\n{container_logs}" if body else container_logs
695
+ if not body:
696
+ body = (
697
+ "agent returned no error detail and no container logs were "
698
+ f"retrievable — inspect the agent sandbox directly (sandbox={sandbox_id})"
699
+ )
700
+ raise RuntimeError(f"A2A task failed ({detail}): {body}")
701
+
702
+ return context
703
+
704
+ async def _fetch_trajectory(self, a2a_url: str, traj_ext: dict, a2a_server_task_id: str, trajectory_output_prefix: str, target_a2a_task_id: str) -> str | None:
705
+ get_method, get_path = A2AAgent.operation(traj_ext, "get")
706
+ store = get_config().get_object_store()
707
+ mode = trajectory_mode(get_method, store, by="task_id")
708
+ if mode is None:
709
+ raise RuntimeError(
710
+ "Agent advertises no trajectory get form this object store can serve"
711
+ )
712
+ # Fetched by a2a_server_task_id, the id the A2A server produced; stored under
713
+ # target_a2a_task_id, the client message id persisted on the conversation as
714
+ # a2a_task_id, so the FE can resolve it (#731).
715
+ upload = None
716
+ if mode == "objects":
717
+ upload = await asyncio.to_thread(
718
+ TrajectoryUpload.to,
719
+ store, trajectory_object_url(trajectory_output_prefix, name=target_a2a_task_id, store=store),
720
+ )
721
+ fetched = await fetch_trajectory(a2a_url + get_path, {"task_id": a2a_server_task_id}, upload=upload)
722
+ return await asyncio.to_thread(store_trajectory, fetched, trajectory_output_prefix, name=target_a2a_task_id)
723
+
724
+ async def _read_env_triggers(self, context: TaskStepContext) -> dict:
725
+ """Snapshot each deployed env's triggers state as {env_id: {trigger_id: status}}; {} when its card offers none; fail-closed on a read error."""
726
+ env_triggers: dict = {}
727
+ for d in context.deployed_envs:
728
+ if not d.supports(EXT_TRIGGERS_URI, "state"):
729
+ env_triggers[d.env_id] = {}
730
+ continue
731
+ last_exc: Optional[Exception] = None
732
+ for attempt in range(3):
733
+ try:
734
+ state = await d.invoke(EXT_TRIGGERS_URI, "state", timeout=15)
735
+ env_triggers[d.env_id] = {t["id"]: t.get("status") for t in state.get("triggers", [])}
736
+ last_exc = None
737
+ break
738
+ except Exception as e:
739
+ last_exc = e
740
+ await asyncio.sleep(0.5 * (attempt + 1))
741
+ if last_exc is not None:
742
+ raise RuntimeError(f"fail-closed: could not read /triggers/state for env '{d.env_id}': {last_exc}")
743
+ return env_triggers
744
+
745
+ async def _decide(self, decide_url: str, turn: int, solver_message: str, context_id: str, env_triggers: dict) -> dict:
746
+ """Call the usersim's /decide for the triggers that should fire this turn."""
747
+ payload = {"turn": turn, "solver_message": solver_message, "context_id": context_id, "env_triggers": env_triggers}
748
+ async with httpx.AsyncClient() as client:
749
+ resp = await client.post(decide_url, json=payload, timeout=self.user_agent_timeout_seconds)
750
+ if resp.status_code >= 400:
751
+ raise RuntimeError(f"agent-trigger /decide failed (HTTP {resp.status_code}): {resp.text}")
752
+ return resp.json()
753
+
754
+ _CAPTURE_BUDGET_SECONDS: ClassVar[float] = 90
755
+
756
+ async def _persist_env_trigger_state(self, context: TaskStepContext) -> None:
757
+ """Snapshot each trigger-registered env's /triggers/state to S3 + a metadata summary.
758
+ Never raises and is bounded by _CAPTURE_BUDGET_SECONDS."""
759
+ try:
760
+ await asyncio.wait_for(
761
+ self._capture_env_trigger_state(context), timeout=self._CAPTURE_BUDGET_SECONDS)
762
+ except Exception as e:
763
+ detail = (f"capture budget of {self._CAPTURE_BUDGET_SECONDS}s exceeded"
764
+ if isinstance(e, TimeoutError) else f"{type(e).__name__}: {str(e)[:200]}")
765
+ logger.warning(f"env trigger state capture aborted (continuing): {detail}")
766
+ registered_env_ids = self._registered_env_ids(context)
767
+ for deployed in context.deployed_envs:
768
+ if deployed.env_id not in registered_env_ids:
769
+ continue
770
+ entries = context.metadata.setdefault("env_trigger_state", {})
771
+ if deployed.env_id not in entries:
772
+ entries[deployed.env_id] = {
773
+ "error": detail,
774
+ "captured_at_utc": datetime.now(timezone.utc).isoformat(),
775
+ "capture_step_id": self.id,
776
+ }
777
+
778
+ @staticmethod
779
+ def _registered_env_ids(context: TaskStepContext) -> set[str]:
780
+ registrations = context.metadata.get("env_trigger_registrations") or []
781
+ return {r["env_id"] for r in registrations if isinstance(r, dict) and r.get("env_id")}
782
+
783
+ async def _capture_env_trigger_state(self, context: TaskStepContext) -> None:
784
+ from agent_env.env.env import gateway_url_of
785
+
786
+ registered_env_ids = self._registered_env_ids(context)
787
+ if not registered_env_ids:
788
+ return
789
+ store = get_config().get_object_store()
790
+ for deployed in context.deployed_envs:
791
+ if deployed.env_id not in registered_env_ids:
792
+ continue
793
+ entry: dict[str, Any] = {
794
+ "captured_at_utc": datetime.now(timezone.utc).isoformat(),
795
+ "capture_step_id": self.id,
796
+ }
797
+ try:
798
+ state = await self._fetch_settled_trigger_state(deployed)
799
+ clock = await self._get_clock_state(deployed)
800
+ # Stamped at the read: the settle poll above can take a minute, which at a high
801
+ # rate is many virtual days of anchor error.
802
+ clock_read_at_utc = datetime.now(timezone.utc).isoformat()
803
+ entry["statuses"] = {t["id"]: t.get("status") for t in state.get("triggers", [])}
804
+ # A recurrence returns to "armed" after every arrival, so only fire_count shows it ran.
805
+ entry["triggers"] = {
806
+ t["id"]: {k: t.get(k) for k in (
807
+ "type", "status", "fire_count", "next_mark", "failure_count", "last_failure_at",
808
+ )}
809
+ for t in state.get("triggers", [])
810
+ }
811
+ # len(events) alone plateaus at the gateway's log cap.
812
+ dropped = state.get("events_dropped") or 0
813
+ entry["event_count"] = len(state.get("events", [])) + dropped
814
+ entry["events_dropped"] = dropped
815
+ entry["capture_is_final"] = self._capture_is_final(state)
816
+ payload = {
817
+ "instance_id": context.instance_id,
818
+ "env_id": deployed.env_id,
819
+ "gateway_url": gateway_url_of(deployed),
820
+ "capture_source": "prompt_agent",
821
+ "captured_at_utc": entry["captured_at_utc"],
822
+ "clock": clock,
823
+ "clock_read_at_utc": clock_read_at_utc,
824
+ "capture_is_final": entry["capture_is_final"],
825
+ "state": state,
826
+ }
827
+ instance_key = context.instance_id or f"adhoc-{uuid.uuid4().hex[:12]}"
828
+ key = (
829
+ f"{get_config().get_artifact_key_prefix()}env_trigger_state/"
830
+ f"instance_id={instance_key}/{deployed.env_id}-{uuid.uuid4().hex[:8]}.json"
831
+ )
832
+ entry["object_url"] = await asyncio.to_thread(
833
+ store.put, key, json.dumps(payload, indent=2, default=str).encode(),
834
+ content_type="application/json",
835
+ )
836
+ except Exception as e:
837
+ logger.warning(f"env trigger state capture failed for env '{deployed.env_id}' (continuing): {e}")
838
+ entry["error"] = f"{type(e).__name__}: {str(e)[:200]}"
839
+ context.metadata.setdefault("env_trigger_state", {})[deployed.env_id] = entry
840
+
841
+ @staticmethod
842
+ def _is_settling(state: dict) -> bool:
843
+ """Whether a trigger still has fire work outstanding.
844
+
845
+ An unbounded recurrence is excluded: it re-enters ``firing`` every arrival and never settles."""
846
+ for t in state.get("triggers", []):
847
+ # `pending` too: the queue itself, not a label, so it outlives a status we do not know.
848
+ if t.get("status") not in TRIGGER_IN_FLIGHT_STATUSES and not t.get("pending"):
849
+ continue
850
+ when = t.get("when")
851
+ if when is None:
852
+ # Older gateway with no ``when``: a scheduled next arrival is the only tell
853
+ # it's a recurrence.
854
+ if t.get("next_mark"):
855
+ continue
856
+ return True
857
+ if when.get("type") == "time" and "every" in when and not ("count" in when or "until" in when):
858
+ continue
859
+ return True
860
+ return False
861
+
862
+ @staticmethod
863
+ def _capture_is_final(state: dict) -> Optional[bool]:
864
+ """Whether the record can still change after this capture; None when not determinable.
865
+
866
+ An older gateway has no ``type``, so an unresolved event-anchored trigger is
867
+ indistinguishable from an action trigger — claiming completeness there would be a false signal."""
868
+ rows = state.get("triggers", [])
869
+ if any("type" not in t for t in rows):
870
+ return None
871
+ return not any(t.get("status") in TRIGGER_IN_FLIGHT_STATUSES or t.get("pending")
872
+ or (t["type"] == "time" and t.get("status") == "armed") for t in rows)
873
+
874
+ async def _fetch_settled_trigger_state(self, deployed, settle_timeout_seconds: float = 60) -> dict:
875
+ """Full /triggers/state, re-polled briefly while a one-shot trigger is mid-``firing``."""
876
+ state = await self._get_trigger_state(deployed)
877
+ deadline = time.monotonic() + settle_timeout_seconds
878
+ while self._is_settling(state) and time.monotonic() < deadline:
879
+ await asyncio.sleep(5)
880
+ state = await self._get_trigger_state(deployed)
881
+ return state
882
+
883
+ async def _get_clock_state(self, deployed) -> Optional[dict]:
884
+ """The gateway clock at capture time; None when its card offers no clock state, or it is unreachable."""
885
+ try:
886
+ # Tight: runs inside the shared capture budget, after the settle poll.
887
+ return await deployed.invoke(EXT_CLOCK_URI, "state", timeout=5)
888
+ except Exception as e:
889
+ logger.warning(f"clock state capture failed for env '{deployed.env_id}' (continuing): {e}")
890
+ return None
891
+
892
+ async def _get_trigger_state(self, deployed) -> dict:
893
+ last_exc: Optional[Exception] = None
894
+ for attempt in range(3):
895
+ try:
896
+ return await deployed.invoke(EXT_TRIGGERS_URI, "state", timeout=15)
897
+ except Exception as e:
898
+ last_exc = e
899
+ await asyncio.sleep(0.5 * (attempt + 1))
900
+ raise RuntimeError(f"could not read /triggers/state for env '{deployed.env_id}': {last_exc}")