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.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- 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}")
|