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,1193 @@
|
|
|
1
|
+
"""Typed Action/State trigger engine (urn:agentenv:triggers/v1) hosted on the gateway:
|
|
2
|
+
post-call detection on watched-role traffic, async fail-open firing, additive fail-loud registration."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import deque
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from mcp.types import CallToolResult, TextContent
|
|
21
|
+
|
|
22
|
+
from .clock import ClockError, _advance, _iso, _parse_duration, _parse_rfc3339
|
|
23
|
+
from .constants import TRIGGER_IN_FLIGHT_STATUSES, TRIGGER_STATUSES
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_PREDICATE_KEYS = ("regex", "equals", "exists")
|
|
28
|
+
_ACTION_TYPES = ("nl", "tool", "permission")
|
|
29
|
+
_TERMINAL_A2A_STATES = ("completed", "failed", "canceled", "rejected")
|
|
30
|
+
# Bounds the per-evaluation catch-up burst; the backlog drains on later evaluations.
|
|
31
|
+
_MAX_CATCHUP = 1000
|
|
32
|
+
# Head + tail of the firing log kept in memory; the driver fires for the gateway's whole life.
|
|
33
|
+
_EVENTS_HEAD = 1000
|
|
34
|
+
_EVENTS_TAIL = 9000
|
|
35
|
+
# Schedule keys state() echoes; excludes the unbounded `check`/`where`.
|
|
36
|
+
_WHEN_SUMMARY_KEYS = ("type", "tool", "repeat", "at", "after", "offset", "every", "count", "until", "seed")
|
|
37
|
+
# Placeholder roots reading the provoking call; a bare name is a check-pipeline binding.
|
|
38
|
+
_CTX_PREFIXES = ("args", "result")
|
|
39
|
+
# Where a barrier waits; a further wait point is a new value here, not a new top-level field.
|
|
40
|
+
_BARRIER_AT = ("provoking_call",)
|
|
41
|
+
_BARRIER_KEYS = ("at", "timeout_seconds")
|
|
42
|
+
_DROP = object() # an unresolved `${x?}` used as a whole value: drop the key / element
|
|
43
|
+
# An allowlist, not a size bound: identifier/metadata keys only, since bodies and addresses are PII.
|
|
44
|
+
_ECHO_KEYS = frozenset({
|
|
45
|
+
"action", "createdTime", "fileName", "kind", "mimeType", "modifiedTime", "name", "role",
|
|
46
|
+
"title", "type",
|
|
47
|
+
})
|
|
48
|
+
_ECHO_ID_RE = re.compile(r"(?:^id|Id|_id)$") # id, fileId, documentId, email_id, …
|
|
49
|
+
_ECHO_VALUE_MAX = 200 # chars; an allowlisted scalar longer than this is a payload wearing an id's name
|
|
50
|
+
_ECHO_DIGEST_CHARS = 16
|
|
51
|
+
|
|
52
|
+
# Firing-log event vocabulary (the GET /triggers/state contract); _emit rejects anything not here.
|
|
53
|
+
_EVENT_KINDS = frozenset({
|
|
54
|
+
"added", "removed", # registration lifecycle
|
|
55
|
+
"detected", # a trigger's condition matched
|
|
56
|
+
"anchored", # an event-anchored time-trigger's mark was stamped
|
|
57
|
+
"reanchored", # a clock re-arm dropped a time-trigger's old-axis mark/backlog
|
|
58
|
+
"action_ok", "action_failed", # one fire-action within a trigger
|
|
59
|
+
"verify_ok", "verify_failed", # typed acceptance of an action
|
|
60
|
+
"fired", "failed", "eval_error", # per trigger; `failed` re-arms an action/state trigger, retires a time one
|
|
61
|
+
})
|
|
62
|
+
# Trigger-level failures; the per-action kinds are details of one of these, so this counts once per failure.
|
|
63
|
+
_FAILURE_KINDS = ("failed", "eval_error")
|
|
64
|
+
_VAR_RE = re.compile(r"\$\{([\w.\[\]*]+\??)\}")
|
|
65
|
+
# The strict pattern is both finder and resolver, so a shape it cannot parse would be sent verbatim.
|
|
66
|
+
_VAR_LOOSE_RE = re.compile(r"\$\{[^{}]*\}?")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TriggerError(ValueError):
|
|
70
|
+
"""Registration validation failure (surfaced as HTTP 400)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TemplateError(LookupError):
|
|
74
|
+
"""A `${args.*}` / `${result.*}` placeholder had no value in the provoking call (fires as action_failed)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _time_mark_spec(value: Any) -> tuple[str, Any]:
|
|
78
|
+
"""('abs', datetime) for an RFC3339 stamp, or ('rel', timedelta) for a t0-relative duration."""
|
|
79
|
+
if isinstance(value, str) and value.startswith("P"):
|
|
80
|
+
return ("rel", _parse_duration(value))
|
|
81
|
+
return ("abs", _parse_rfc3339(value))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dur_or_400(value: Any, where: str) -> timedelta:
|
|
85
|
+
try:
|
|
86
|
+
return _parse_duration(value)
|
|
87
|
+
except ClockError as e:
|
|
88
|
+
raise TriggerError(f"{where}: {e}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mark_or_400(value: Any, where: str) -> tuple[str, Any]:
|
|
92
|
+
try:
|
|
93
|
+
return _time_mark_spec(value)
|
|
94
|
+
except ClockError as e:
|
|
95
|
+
raise TriggerError(f"{where}: {e}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _make_rng(spec: dict) -> random.Random | None:
|
|
99
|
+
"""A per-trigger seeded RNG for a stochastic time-trigger, else None."""
|
|
100
|
+
when = spec["when"]
|
|
101
|
+
if when["type"] == "time" and isinstance(when.get("every"), dict):
|
|
102
|
+
return random.Random(when["seed"])
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _now() -> str:
|
|
107
|
+
return datetime.now(timezone.utc).isoformat()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _as_text(value: Any) -> str:
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
return value
|
|
113
|
+
return json.dumps(value, default=str)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _eval_predicate(pred: dict, value: Any) -> bool:
|
|
117
|
+
if "regex" in pred:
|
|
118
|
+
return re.search(pred["regex"], _as_text(value)) is not None
|
|
119
|
+
if "equals" in pred:
|
|
120
|
+
return value == pred["equals"]
|
|
121
|
+
non_empty = value is not None and value != "" and value != []
|
|
122
|
+
return non_empty == pred["exists"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _apply_path(data: Any, path: str) -> Any:
|
|
126
|
+
"""Dot path with [n] indices and a trailing [*] meaning 'the list itself'."""
|
|
127
|
+
current = data
|
|
128
|
+
for segment in path.split("."):
|
|
129
|
+
m = re.fullmatch(r"(\w+)((?:\[(?:\d+|\*)\])*)", segment)
|
|
130
|
+
if m is None:
|
|
131
|
+
raise TriggerError(f"invalid extract path segment {segment!r}")
|
|
132
|
+
key, indices = m.group(1), m.group(2)
|
|
133
|
+
if not isinstance(current, dict) or key not in current:
|
|
134
|
+
return None
|
|
135
|
+
current = current[key]
|
|
136
|
+
for idx in re.findall(r"\[(\d+|\*)\]", indices):
|
|
137
|
+
if idx == "*":
|
|
138
|
+
continue
|
|
139
|
+
if not isinstance(current, list) or int(idx) >= len(current):
|
|
140
|
+
return None
|
|
141
|
+
current = current[int(idx)]
|
|
142
|
+
return current
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _apply_extract(extract: Any, data: Any, raw_text: str) -> Any:
|
|
146
|
+
if extract is None:
|
|
147
|
+
return raw_text
|
|
148
|
+
if isinstance(extract, str):
|
|
149
|
+
return _apply_path(data, extract)
|
|
150
|
+
items = _apply_path(data, extract["path"])
|
|
151
|
+
if not isinstance(items, list):
|
|
152
|
+
return None
|
|
153
|
+
for item in items:
|
|
154
|
+
if not isinstance(item, dict):
|
|
155
|
+
continue
|
|
156
|
+
match = extract.get("match", {})
|
|
157
|
+
if all(_eval_predicate(pred, item.get(field)) for field, pred in match.items()):
|
|
158
|
+
return item.get(extract["take"]) if extract.get("take") else item
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _result_data(result: Any) -> Any:
|
|
163
|
+
"""A CallToolResult's text parsed as JSON (dict / list / scalar), the raw text when it is not JSON, or None."""
|
|
164
|
+
content = getattr(result, "content", None)
|
|
165
|
+
if not content:
|
|
166
|
+
return None
|
|
167
|
+
text = "\n".join(c.text for c in content if getattr(c, "type", None) == "text")
|
|
168
|
+
if not text:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
return json.loads(text)
|
|
172
|
+
except (ValueError, TypeError):
|
|
173
|
+
return text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ctx_path(name: str) -> tuple[str | None, str]:
|
|
177
|
+
"""The `args`/`result` root a placeholder or `where` key addresses and the path beneath it;
|
|
178
|
+
`(None, "")` for a check-pipeline binding name. Pass a name with any trailing `?` removed —
|
|
179
|
+
the optional marker belongs to placeholders, not to `where` keys."""
|
|
180
|
+
head, _, path = name.partition(".")
|
|
181
|
+
return (head, path) if head in _CTX_PREFIXES else (None, "")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _ctx_lookup(name: str, bindings: dict, ctx: dict | None) -> tuple[bool, Any]:
|
|
185
|
+
"""(found, value) for a placeholder name: check-pipeline bindings win; `args` / `result` roots (with an
|
|
186
|
+
optional dotted path) read the provoking call's arguments / parsed result."""
|
|
187
|
+
name = name.rstrip("?")
|
|
188
|
+
if name in bindings:
|
|
189
|
+
return True, bindings[name]
|
|
190
|
+
root_name, path = _ctx_path(name)
|
|
191
|
+
if root_name is None or ctx is None:
|
|
192
|
+
return False, None
|
|
193
|
+
root = ctx.get(root_name)
|
|
194
|
+
if not path:
|
|
195
|
+
return root is not None, root
|
|
196
|
+
value = _apply_path(root, path) if isinstance(root, dict) else None
|
|
197
|
+
return value is not None, value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve(name: str, literal: str, bindings: dict, ctx: dict | None) -> tuple[bool, Any]:
|
|
201
|
+
"""(found, value) for one placeholder. An unresolved required `args.*` / `result.*` is fail-loud —
|
|
202
|
+
the target tool must never receive the literal `${...}` text."""
|
|
203
|
+
found, value = _ctx_lookup(name, bindings, ctx)
|
|
204
|
+
if not found and not name.endswith("?") and _ctx_path(name.rstrip("?"))[0] is not None:
|
|
205
|
+
raise TemplateError(f"unresolved placeholder {literal}")
|
|
206
|
+
return found, value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_ctx_attempt(token: str) -> bool:
|
|
210
|
+
"""Whether placeholder-shaped text is reaching for the provoking call, whitespace aside.
|
|
211
|
+
|
|
212
|
+
For nl instructions, which are never templated: `${result.x}` there can only be an author
|
|
213
|
+
expecting substitution, while `${HOME}` is ordinary prose. All whitespace is removed before the
|
|
214
|
+
root is read, so `${result .x}` is recognised as the attempt it is rather than mistaken for a
|
|
215
|
+
shell variable -- the strict pattern would not resolve it either, so it must not register."""
|
|
216
|
+
compact = re.sub(r"\s+", "", token)
|
|
217
|
+
return any(compact.startswith(f"${{{root}.") for root in _CTX_PREFIXES)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _template(args: Any, bindings: dict, ctx: dict | None = None) -> Any:
|
|
221
|
+
"""Substitute `${...}` placeholders recursively through dicts and lists. A string that is exactly one
|
|
222
|
+
placeholder resolves to the raw value; an embedded one stringifies. Unresolved: a required
|
|
223
|
+
`args.*` / `result.*` raises, a bare name stays literal, an optional whole value is dropped."""
|
|
224
|
+
def sub(value: Any) -> Any:
|
|
225
|
+
# Dropping the key rather than sending null is what lets the target tool apply its own default.
|
|
226
|
+
if isinstance(value, dict):
|
|
227
|
+
return {k: r for k, v in value.items() if (r := sub(v)) is not _DROP}
|
|
228
|
+
if isinstance(value, list):
|
|
229
|
+
return [r for v in value if (r := sub(v)) is not _DROP]
|
|
230
|
+
if not isinstance(value, str):
|
|
231
|
+
return value
|
|
232
|
+
whole = _VAR_RE.fullmatch(value)
|
|
233
|
+
if whole is not None:
|
|
234
|
+
name = whole.group(1)
|
|
235
|
+
found, resolved = _resolve(name, value, bindings, ctx)
|
|
236
|
+
if not found:
|
|
237
|
+
return _DROP if name.endswith("?") else value
|
|
238
|
+
return resolved if _ctx_path(name.rstrip("?"))[0] else str(resolved)
|
|
239
|
+
|
|
240
|
+
def repl(m: re.Match) -> str:
|
|
241
|
+
found, resolved = _resolve(m.group(1), m.group(0), bindings, ctx)
|
|
242
|
+
if not found:
|
|
243
|
+
return "" if m.group(1).endswith("?") else m.group(0)
|
|
244
|
+
return resolved if isinstance(resolved, str) else json.dumps(resolved, default=str)
|
|
245
|
+
return _VAR_RE.sub(repl, value)
|
|
246
|
+
|
|
247
|
+
out = sub(args)
|
|
248
|
+
return args if out is _DROP else out # a whole-value drop has nowhere to go at the top level
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _where_value(ctx: dict, field: str) -> Any:
|
|
252
|
+
"""The value a `where` key names: `result.<path>` / `args.<path>` via _apply_path, a bare key is a
|
|
253
|
+
top-level argument (the v1 behaviour)."""
|
|
254
|
+
root_name, path = _ctx_path(field)
|
|
255
|
+
if root_name is not None and path:
|
|
256
|
+
root = ctx.get(root_name)
|
|
257
|
+
return _apply_path(root, path) if isinstance(root, dict) else None
|
|
258
|
+
args = ctx.get("args")
|
|
259
|
+
return args.get(field) if isinstance(args, dict) else None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _echoable(key: str) -> bool:
|
|
263
|
+
return key in _ECHO_KEYS or _ECHO_ID_RE.search(key) is not None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _shape(value: Any) -> dict:
|
|
267
|
+
"""What a redacted value was, without any of what it said."""
|
|
268
|
+
shape: dict[str, Any] = {"type": type(value).__name__}
|
|
269
|
+
if isinstance(value, (str, bytes, list, dict)):
|
|
270
|
+
shape["len"] = len(value)
|
|
271
|
+
return {"redacted": shape}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _echo_args(value: Any, key: str | None = None) -> Any:
|
|
275
|
+
"""Trajectory-safe projection of a mirror's resolved args.
|
|
276
|
+
|
|
277
|
+
Allowlisted identifier/metadata scalars pass through so a grader can see *which* object the
|
|
278
|
+
mirror wrote; every other value becomes a `{"redacted": {...}}` shape descriptor. Structure is
|
|
279
|
+
preserved either way, so the arg *names* a mirror sent stay reviewable — it is only the values
|
|
280
|
+
that are withheld. Keys are matched at every depth, so a nested `{"file": {"content": ...}}`
|
|
281
|
+
is redacted just like a top-level one.
|
|
282
|
+
"""
|
|
283
|
+
if isinstance(value, dict):
|
|
284
|
+
return {k: _echo_args(v, k) for k, v in value.items()}
|
|
285
|
+
if isinstance(value, list):
|
|
286
|
+
return [_echo_args(v, key) for v in value]
|
|
287
|
+
if key is not None and _echoable(key) and isinstance(value, (str, int, float, bool, type(None))):
|
|
288
|
+
# A bool is an int subclass; both are unconditionally safe. Only a str can hide a payload.
|
|
289
|
+
if isinstance(value, str) and len(value) > _ECHO_VALUE_MAX:
|
|
290
|
+
return _shape(value)
|
|
291
|
+
return value
|
|
292
|
+
return _shape(value)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _strings(value: Any) -> list[str]:
|
|
296
|
+
"""Every string anywhere in an args structure, so the two placeholder scans share one walk."""
|
|
297
|
+
if isinstance(value, dict):
|
|
298
|
+
return [t for v in value.values() for t in _strings(v)]
|
|
299
|
+
if isinstance(value, list):
|
|
300
|
+
return [t for v in value for t in _strings(v)]
|
|
301
|
+
return [value] if isinstance(value, str) else []
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _placeholders(value: Any) -> list[str]:
|
|
305
|
+
return [n for text in _strings(value) for n in _VAR_RE.findall(text)]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _inert_placeholders(value: Any) -> list[str]:
|
|
309
|
+
"""Placeholder-shaped text the templater will not act on, so the tool would receive it verbatim."""
|
|
310
|
+
return [m for text in _strings(value) for m in _VAR_LOOSE_RE.findall(text)
|
|
311
|
+
if _VAR_RE.fullmatch(m) is None]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _validate_path(path: str, where: str) -> None:
|
|
315
|
+
for segment in path.split("."):
|
|
316
|
+
if re.fullmatch(r"\w+((?:\[(?:\d+|\*)\])*)", segment) is None:
|
|
317
|
+
raise TriggerError(f"{where}: invalid path segment {segment!r} in {path!r}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _validate_placeholders(value: Any, where: str, allow_ctx: bool) -> None:
|
|
321
|
+
"""Fail-loud at registration: `${args.*}` / `${result.*}` only on action triggers (they alone carry a
|
|
322
|
+
provoking call), any other placeholder must be a bare check-binding name. A shape the templater
|
|
323
|
+
cannot parse is rejected first, or it would reach the tool as literal text."""
|
|
324
|
+
inert = _inert_placeholders(value)
|
|
325
|
+
if inert:
|
|
326
|
+
raise TriggerError(f"{where}: {inert[0]!r} is not a placeholder the engine can resolve; "
|
|
327
|
+
f"expected ${{args.<path>}}, ${{result.<path>}} or ${{<check binding>}}")
|
|
328
|
+
for name in _placeholders(value):
|
|
329
|
+
name = name.rstrip("?")
|
|
330
|
+
root_name, path = _ctx_path(name)
|
|
331
|
+
if root_name is None:
|
|
332
|
+
if re.fullmatch(r"\w+", name) is None:
|
|
333
|
+
raise TriggerError(f"{where}: placeholder ${{{name}}} must be a check binding name or an args./result. path")
|
|
334
|
+
elif not allow_ctx:
|
|
335
|
+
raise TriggerError(f"{where}: ${{{name}}} needs a provoking tool call; only action triggers carry args/result")
|
|
336
|
+
elif path:
|
|
337
|
+
_validate_path(path, where)
|
|
338
|
+
elif "." in name:
|
|
339
|
+
raise TriggerError(f"{where}: placeholder ${{{name}}} has an empty path")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_tools(check: dict) -> list[str]:
|
|
343
|
+
return [step["tool"] for step in check["steps"]]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _validate_predicate(pred: Any, where: str) -> None:
|
|
347
|
+
if not isinstance(pred, dict) or sum(k in pred for k in _PREDICATE_KEYS) != 1:
|
|
348
|
+
raise TriggerError(f"{where}: predicate must have exactly one of {_PREDICATE_KEYS}")
|
|
349
|
+
if "regex" in pred:
|
|
350
|
+
try:
|
|
351
|
+
re.compile(pred["regex"])
|
|
352
|
+
except re.error as e:
|
|
353
|
+
raise TriggerError(f"{where}: invalid regex: {e}")
|
|
354
|
+
if "exists" in pred and not isinstance(pred["exists"], bool):
|
|
355
|
+
raise TriggerError(f"{where}: exists must be a boolean")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _validate_check(check: Any, where: str) -> dict:
|
|
359
|
+
if not isinstance(check, dict):
|
|
360
|
+
raise TriggerError(f"{where}: check must be an object")
|
|
361
|
+
steps = check.get("steps", [check] if "tool" in check else None)
|
|
362
|
+
if not isinstance(steps, list) or not steps:
|
|
363
|
+
raise TriggerError(f"{where}: check needs 'steps' or a single-step tool/predicate form")
|
|
364
|
+
for i, step in enumerate(steps):
|
|
365
|
+
w = f"{where}.steps[{i}]"
|
|
366
|
+
if not isinstance(step.get("tool"), str) or not step["tool"]:
|
|
367
|
+
raise TriggerError(f"{w}: tool must be a non-empty string")
|
|
368
|
+
if not isinstance(step.get("args", {}), dict):
|
|
369
|
+
raise TriggerError(f"{w}: args must be an object")
|
|
370
|
+
extract = step.get("extract")
|
|
371
|
+
if extract is not None and not isinstance(extract, str):
|
|
372
|
+
if not isinstance(extract, dict) or "path" not in extract:
|
|
373
|
+
raise TriggerError(f"{w}: extract must be a dot-path string or {{path, match, take}}")
|
|
374
|
+
for field, pred in extract.get("match", {}).items():
|
|
375
|
+
_validate_predicate(pred, f"{w}.extract.match.{field}")
|
|
376
|
+
if "predicate" in step:
|
|
377
|
+
_validate_predicate(step["predicate"], f"{w}.predicate")
|
|
378
|
+
if "predicate" not in steps[-1]:
|
|
379
|
+
raise TriggerError(f"{where}: the last check step must carry a predicate")
|
|
380
|
+
return {"steps": steps}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class TriggerEngine:
|
|
384
|
+
def __init__(self, gateway):
|
|
385
|
+
self._gw = gateway
|
|
386
|
+
self._executor: dict | None = None
|
|
387
|
+
self._executor_role: str | None = None
|
|
388
|
+
self._watch_roles: set[str] = set()
|
|
389
|
+
self._config_set = False
|
|
390
|
+
self._triggers: dict[str, dict] = {}
|
|
391
|
+
self._events: list[dict] = [] # head of the firing log
|
|
392
|
+
self._events_tail: deque[dict] = deque(maxlen=_EVENTS_TAIL) # most-recent window
|
|
393
|
+
self._seq = 0
|
|
394
|
+
self._tasks: set[asyncio.Task] = set()
|
|
395
|
+
self._readonly_tools: set[str] | None = None
|
|
396
|
+
self._has_time_triggers = False
|
|
397
|
+
self._driver_task: asyncio.Task | None = None
|
|
398
|
+
self._driver_interval = 1.0 # real seconds between autonomous time-trigger polls
|
|
399
|
+
self._time_trigger_gate = asyncio.Event() # parks the driver while no time-trigger is armed
|
|
400
|
+
|
|
401
|
+
def register(self, body: Any) -> dict:
|
|
402
|
+
"""Additively add each trigger by id (idempotent upsert; conflicting id or bad shape -> 400); watch_roles/executor are set once."""
|
|
403
|
+
if not isinstance(body, dict):
|
|
404
|
+
raise TriggerError("body must be a JSON object")
|
|
405
|
+
watch_roles = self._resolve_watch_roles(body)
|
|
406
|
+
executor = self._resolve_executor(body, watch_roles)
|
|
407
|
+
triggers = body.get("triggers")
|
|
408
|
+
if not isinstance(triggers, list):
|
|
409
|
+
raise TriggerError("triggers must be a list")
|
|
410
|
+
known = self._known_tools()
|
|
411
|
+
batch_order: list[str] = []
|
|
412
|
+
to_add: list[tuple[str, dict]] = []
|
|
413
|
+
for raw in triggers:
|
|
414
|
+
if not isinstance(raw, dict):
|
|
415
|
+
raise TriggerError("every trigger needs a non-empty string id")
|
|
416
|
+
spec = copy.deepcopy(raw) # validation normalizes in place; never mutate the caller's dict
|
|
417
|
+
tid = spec.get("id")
|
|
418
|
+
if tid is None:
|
|
419
|
+
tid = f"trg_{uuid.uuid4().hex[:12]}"
|
|
420
|
+
spec["id"] = tid
|
|
421
|
+
if not isinstance(tid, str) or not tid:
|
|
422
|
+
raise TriggerError("every trigger needs a non-empty string id")
|
|
423
|
+
if tid in batch_order:
|
|
424
|
+
raise TriggerError(f"trigger '{tid}': duplicate id")
|
|
425
|
+
batch_order.append(tid)
|
|
426
|
+
spec = self._normalize_trigger(spec, executor)
|
|
427
|
+
if known is not None:
|
|
428
|
+
self._check_tools_known(spec, known)
|
|
429
|
+
existing = self._triggers.get(tid)
|
|
430
|
+
if existing is not None:
|
|
431
|
+
if existing["spec"] != spec:
|
|
432
|
+
raise TriggerError(f"trigger '{tid}': already registered with a different spec — remove it first or use a new id")
|
|
433
|
+
continue # identical re-add: no-op (a fired trigger stays fired)
|
|
434
|
+
to_add.append((tid, spec))
|
|
435
|
+
self._readonly_tools = None
|
|
436
|
+
self._watch_roles = watch_roles
|
|
437
|
+
self._executor = executor
|
|
438
|
+
self._executor_role = executor["role"] if executor else None
|
|
439
|
+
self._config_set = True
|
|
440
|
+
for tid, spec in to_add:
|
|
441
|
+
self._triggers[tid] = {"spec": spec, "status": "armed", "detected_at": None,
|
|
442
|
+
"fired_at": None, "evaluating": False,
|
|
443
|
+
"next_mark": None, "resolved": False, "fire_count": 0,
|
|
444
|
+
"failure_count": 0, "last_failure_at": None,
|
|
445
|
+
"rng": _make_rng(spec), "mark_gen": None,
|
|
446
|
+
"pending": deque(), "task": None}
|
|
447
|
+
self._emit("added", tid)
|
|
448
|
+
self._recompute_time_flag()
|
|
449
|
+
return {"ok": True, "added": batch_order, "all": list(self._triggers)}
|
|
450
|
+
|
|
451
|
+
def _known_tools(self) -> set[str] | None:
|
|
452
|
+
"""Every tool name the gateway can route to, or None before discovery (then unknown tools surface at fire time)."""
|
|
453
|
+
server_tools = getattr(self._gw, "_server_tools", None)
|
|
454
|
+
if not server_tools or not getattr(self._gw, "_tools_discovered", True):
|
|
455
|
+
return None
|
|
456
|
+
known = {tool.name for tools in server_tools.values() for tool in tools}
|
|
457
|
+
mcp = getattr(self._gw, "_mcp", None)
|
|
458
|
+
if mcp is not None:
|
|
459
|
+
known.update(mcp._tool_manager._tools)
|
|
460
|
+
return known
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _check_tools_known(spec: dict, known: set[str]) -> None:
|
|
464
|
+
tid = spec["id"]
|
|
465
|
+
when = spec["when"]
|
|
466
|
+
named: list[tuple[str, str]] = []
|
|
467
|
+
if when["type"] == "action":
|
|
468
|
+
named.append((when["tool"], "when.tool"))
|
|
469
|
+
elif when["type"] == "state":
|
|
470
|
+
named.extend((t, "when.check") for t in _check_tools(when["check"]))
|
|
471
|
+
for i, action in enumerate(spec["actions"]):
|
|
472
|
+
if action["type"] == "tool":
|
|
473
|
+
named.append((action["tool"], f"actions[{i}].tool"))
|
|
474
|
+
if "verify" in action:
|
|
475
|
+
named.extend((t, f"actions[{i}].verify") for t in _check_tools(action["verify"]))
|
|
476
|
+
for tool, where in named:
|
|
477
|
+
if tool not in known:
|
|
478
|
+
raise TriggerError(f"trigger '{tid}': {where} names unknown tool {tool!r}")
|
|
479
|
+
|
|
480
|
+
def remove(self, body: Any) -> dict:
|
|
481
|
+
"""Disarm + delete triggers by id (idempotent: unknown ids are ignored). Removed ids may be re-added."""
|
|
482
|
+
if not isinstance(body, dict):
|
|
483
|
+
raise TriggerError("body must be a JSON object")
|
|
484
|
+
ids = body.get("ids")
|
|
485
|
+
if not isinstance(ids, list) or not all(isinstance(i, str) and i for i in ids):
|
|
486
|
+
raise TriggerError("ids must be a list of non-empty strings")
|
|
487
|
+
removed = []
|
|
488
|
+
for tid in ids:
|
|
489
|
+
if self._triggers.pop(tid, None) is not None:
|
|
490
|
+
removed.append(tid)
|
|
491
|
+
self._emit("removed", tid)
|
|
492
|
+
self._recompute_time_flag()
|
|
493
|
+
return {"ok": True, "removed": removed}
|
|
494
|
+
|
|
495
|
+
def clear(self) -> dict:
|
|
496
|
+
"""Full reset — triggers, config, and the event log (setup/testing only; never mid-task)."""
|
|
497
|
+
for task in list(self._tasks):
|
|
498
|
+
task.cancel()
|
|
499
|
+
self._tasks.clear()
|
|
500
|
+
self._triggers = {}
|
|
501
|
+
self._events = []
|
|
502
|
+
self._events_tail.clear()
|
|
503
|
+
self._seq = 0
|
|
504
|
+
self._watch_roles = set()
|
|
505
|
+
self._executor = None
|
|
506
|
+
self._executor_role = None
|
|
507
|
+
self._config_set = False
|
|
508
|
+
self._readonly_tools = None
|
|
509
|
+
self._recompute_time_flag()
|
|
510
|
+
return {"ok": True}
|
|
511
|
+
|
|
512
|
+
def _recompute_time_flag(self) -> None:
|
|
513
|
+
# "firing" counts: a recurrence mid-fire returns to "armed" and still needs the driver.
|
|
514
|
+
self._has_time_triggers = any(
|
|
515
|
+
t["spec"]["when"]["type"] == "time" and t["status"] in ("armed", "firing")
|
|
516
|
+
for t in self._triggers.values())
|
|
517
|
+
if self._has_time_triggers:
|
|
518
|
+
self._time_trigger_gate.set()
|
|
519
|
+
else:
|
|
520
|
+
self._time_trigger_gate.clear()
|
|
521
|
+
|
|
522
|
+
def start_driver(self) -> None:
|
|
523
|
+
"""Start the background poller (idempotent; requires a running loop)."""
|
|
524
|
+
if self._driver_task is None or self._driver_task.done():
|
|
525
|
+
self._driver_task = asyncio.get_running_loop().create_task(self._drive_time())
|
|
526
|
+
|
|
527
|
+
async def _drive_time(self) -> None:
|
|
528
|
+
"""Poll armed time-triggers every _driver_interval real seconds; parks while none are armed."""
|
|
529
|
+
while True:
|
|
530
|
+
try:
|
|
531
|
+
await self._time_trigger_gate.wait() # park until a time-trigger is armed
|
|
532
|
+
await asyncio.sleep(self._driver_interval)
|
|
533
|
+
if self._has_time_triggers and self._gw._clock.armed:
|
|
534
|
+
self._eval_time_triggers()
|
|
535
|
+
except asyncio.CancelledError:
|
|
536
|
+
raise
|
|
537
|
+
except Exception:
|
|
538
|
+
logger.exception("clock time-driver tick failed")
|
|
539
|
+
|
|
540
|
+
async def stop_driver(self) -> None:
|
|
541
|
+
"""Cancel the background poller on gateway teardown (idempotent)."""
|
|
542
|
+
task, self._driver_task = self._driver_task, None
|
|
543
|
+
if task is not None:
|
|
544
|
+
task.cancel()
|
|
545
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
546
|
+
await task
|
|
547
|
+
|
|
548
|
+
def _resolve_watch_roles(self, body: dict) -> set[str]:
|
|
549
|
+
raw = body.get("watch_roles")
|
|
550
|
+
if raw is None:
|
|
551
|
+
return self._watch_roles if self._config_set else {"default"}
|
|
552
|
+
if not isinstance(raw, list) or not all(isinstance(r, str) and r for r in raw):
|
|
553
|
+
raise TriggerError("watch_roles must be a list of non-empty strings")
|
|
554
|
+
new = set(raw)
|
|
555
|
+
if self._config_set and new != self._watch_roles:
|
|
556
|
+
raise TriggerError(f"watch_roles {sorted(new)} conflicts with the already-registered {sorted(self._watch_roles)} — clear to change it")
|
|
557
|
+
return new
|
|
558
|
+
|
|
559
|
+
def _resolve_executor(self, body: dict, watch_roles: set[str]) -> dict | None:
|
|
560
|
+
raw = body.get("executor")
|
|
561
|
+
if raw is None:
|
|
562
|
+
return self._executor
|
|
563
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("a2a_url"), str) or not raw["a2a_url"]:
|
|
564
|
+
raise TriggerError("executor.a2a_url must be a non-empty string")
|
|
565
|
+
role = raw.get("role")
|
|
566
|
+
if not isinstance(role, str) or not role:
|
|
567
|
+
raise TriggerError("executor.role must be a non-empty string (the deployed executor's AgentEnv-Role, kept out of watch_roles so its own tool calls never fire triggers)")
|
|
568
|
+
if role in watch_roles:
|
|
569
|
+
raise TriggerError(f"executor.role {role!r} must not be in watch_roles {sorted(watch_roles)} — the executor's own tool calls would fire triggers (cascade)")
|
|
570
|
+
normalized = {"a2a_url": raw["a2a_url"].rstrip("/"),
|
|
571
|
+
"timeout_seconds": float(raw.get("timeout_seconds", 120)),
|
|
572
|
+
"role": role}
|
|
573
|
+
if self._executor is not None and self._executor != normalized:
|
|
574
|
+
raise TriggerError("executor conflicts with the already-registered executor — clear to change it")
|
|
575
|
+
return normalized
|
|
576
|
+
|
|
577
|
+
@staticmethod
|
|
578
|
+
def _normalize_barrier(spec: dict, tid: str, has_ctx: bool) -> None:
|
|
579
|
+
"""Validate `barrier` in place. Absent stays absent and the timeout is resolved at wait time,
|
|
580
|
+
so an idempotent re-add still matches even after the gateway default moves."""
|
|
581
|
+
barrier = spec.get("barrier")
|
|
582
|
+
if barrier is None:
|
|
583
|
+
spec.pop("barrier", None)
|
|
584
|
+
return
|
|
585
|
+
w = f"trigger '{tid}': barrier"
|
|
586
|
+
if not isinstance(barrier, dict):
|
|
587
|
+
raise TriggerError(f"{w} must be an object, e.g. {{\"at\": \"provoking_call\"}}")
|
|
588
|
+
unknown = sorted(set(barrier) - set(_BARRIER_KEYS))
|
|
589
|
+
if unknown:
|
|
590
|
+
raise TriggerError(f"{w} has unknown key(s) {unknown}; expected {list(_BARRIER_KEYS)}")
|
|
591
|
+
at = barrier.get("at")
|
|
592
|
+
if at not in _BARRIER_AT:
|
|
593
|
+
raise TriggerError(f"{w}.at must be one of {list(_BARRIER_AT)} (got {at!r})")
|
|
594
|
+
if at == "provoking_call" and not has_ctx:
|
|
595
|
+
raise TriggerError(f"{w}.at 'provoking_call' is only valid on action triggers — they alone have a "
|
|
596
|
+
"provoking call to hold")
|
|
597
|
+
timeout = barrier.get("timeout_seconds")
|
|
598
|
+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
599
|
+
or not math.isfinite(timeout) or timeout <= 0):
|
|
600
|
+
raise TriggerError(f"{w}.timeout_seconds must be a positive, finite number (got {timeout!r})")
|
|
601
|
+
|
|
602
|
+
def _normalize_trigger(self, spec: dict, executor: dict | None) -> dict:
|
|
603
|
+
"""Validate + normalize a trigger spec in place (fail-loud); returns it, so pass a copy."""
|
|
604
|
+
tid = spec["id"]
|
|
605
|
+
when = spec.get("when")
|
|
606
|
+
if not isinstance(when, dict) or when.get("type") not in ("action", "state", "time"):
|
|
607
|
+
raise TriggerError(f"trigger '{tid}': when.type must be 'action', 'state', or 'time'")
|
|
608
|
+
if when["type"] == "action":
|
|
609
|
+
self._normalize_action_when(when, tid)
|
|
610
|
+
elif when["type"] == "state":
|
|
611
|
+
if "repeat" in when:
|
|
612
|
+
raise TriggerError(f"trigger '{tid}': when.repeat is only valid on action triggers")
|
|
613
|
+
when["check"] = _validate_check(when.get("check"), f"trigger '{tid}': when.check")
|
|
614
|
+
for j, step in enumerate(when["check"]["steps"]):
|
|
615
|
+
_validate_placeholders(step.get("args", {}), f"trigger '{tid}': when.check.steps[{j}].args", allow_ctx=False)
|
|
616
|
+
else:
|
|
617
|
+
if "repeat" in when:
|
|
618
|
+
raise TriggerError(f"trigger '{tid}': when.repeat is only valid on action triggers (time triggers recur via 'every')")
|
|
619
|
+
self._normalize_time_when(when, tid)
|
|
620
|
+
has_ctx = when["type"] == "action"
|
|
621
|
+
self._normalize_barrier(spec, tid, has_ctx)
|
|
622
|
+
actions = spec.get("actions")
|
|
623
|
+
if not isinstance(actions, list):
|
|
624
|
+
raise TriggerError(f"trigger '{tid}': actions must be a list")
|
|
625
|
+
for i, action in enumerate(actions):
|
|
626
|
+
self._normalize_action(action, f"trigger '{tid}': actions[{i}]", has_ctx, executor)
|
|
627
|
+
return spec
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _normalize_action_when(when: dict, tid: str) -> None:
|
|
631
|
+
"""Validate a when.type=='action' spec: the provoking tool, its `where` predicates, `repeat`."""
|
|
632
|
+
if not isinstance(when.get("tool"), str) or not when["tool"]:
|
|
633
|
+
raise TriggerError(f"trigger '{tid}': when.tool must be a non-empty string")
|
|
634
|
+
where = when.get("where", {})
|
|
635
|
+
if not isinstance(where, dict):
|
|
636
|
+
raise TriggerError(f"trigger '{tid}': when.where must be an object")
|
|
637
|
+
for field, pred in where.items():
|
|
638
|
+
_validate_predicate(pred, f"trigger '{tid}': where.{field}")
|
|
639
|
+
_, path = _ctx_path(field)
|
|
640
|
+
if path:
|
|
641
|
+
_validate_path(path, f"trigger '{tid}': where.{field}")
|
|
642
|
+
if "repeat" in when and not isinstance(when["repeat"], bool):
|
|
643
|
+
raise TriggerError(f"trigger '{tid}': when.repeat must be a boolean")
|
|
644
|
+
|
|
645
|
+
@staticmethod
|
|
646
|
+
def _normalize_action(action: Any, w: str, has_ctx: bool, executor: dict | None) -> None:
|
|
647
|
+
"""Validate one fire-action in place, plus its optional `verify` check."""
|
|
648
|
+
atype = action.get("type") if isinstance(action, dict) else None
|
|
649
|
+
if atype not in _ACTION_TYPES:
|
|
650
|
+
raise TriggerError(f"{w}: type must be one of {_ACTION_TYPES}")
|
|
651
|
+
if atype == "nl":
|
|
652
|
+
if not isinstance(action.get("instruction"), str) or not action["instruction"]:
|
|
653
|
+
raise TriggerError(f"{w}: instruction must be a non-empty string")
|
|
654
|
+
# nl is never templated; only context-rooted shapes reject, since prose carries `${HOME}`.
|
|
655
|
+
for token in _VAR_LOOSE_RE.findall(action["instruction"]):
|
|
656
|
+
if _is_ctx_attempt(token):
|
|
657
|
+
raise TriggerError(f"{w}: instruction contains {token!r}, but nl instructions are "
|
|
658
|
+
f"not templated; read the provoking call from a tool action's args")
|
|
659
|
+
if executor is None:
|
|
660
|
+
raise TriggerError(f"{w}: nl actions require an executor")
|
|
661
|
+
elif atype == "tool":
|
|
662
|
+
if not isinstance(action.get("tool"), str) or not action["tool"]:
|
|
663
|
+
raise TriggerError(f"{w}: tool must be a non-empty string")
|
|
664
|
+
if not isinstance(action.get("args", {}), dict):
|
|
665
|
+
raise TriggerError(f"{w}: args must be an object")
|
|
666
|
+
_validate_placeholders(action.get("args", {}), f"{w}.args", allow_ctx=has_ctx)
|
|
667
|
+
else:
|
|
668
|
+
if action.get("action") not in ("enable", "disable"):
|
|
669
|
+
raise TriggerError(f"{w}: action must be 'enable' or 'disable'")
|
|
670
|
+
if not isinstance(action.get("role"), str) or not action["role"]:
|
|
671
|
+
raise TriggerError(f"{w}: role must be a non-empty string")
|
|
672
|
+
tools = action.get("tools")
|
|
673
|
+
if tools != "*" and (not isinstance(tools, list) or not all(isinstance(t, str) for t in tools)):
|
|
674
|
+
raise TriggerError(f"{w}: tools must be a list of strings or '*'")
|
|
675
|
+
if "verify" in action:
|
|
676
|
+
action["verify"] = _validate_check(action["verify"], f"{w}.verify")
|
|
677
|
+
for j, step in enumerate(action["verify"]["steps"]):
|
|
678
|
+
_validate_placeholders(step.get("args", {}), f"{w}.verify.steps[{j}].args", allow_ctx=has_ctx)
|
|
679
|
+
|
|
680
|
+
def _normalize_time_when(self, when: dict, tid: str) -> None:
|
|
681
|
+
"""Validate a when.type=='time' spec fail-loud, leaving it JSON-clean."""
|
|
682
|
+
w = f"trigger '{tid}'"
|
|
683
|
+
has_at, has_after, has_every = "at" in when, "after" in when, "every" in when
|
|
684
|
+
if not (has_at or has_after or has_every):
|
|
685
|
+
raise TriggerError(f"{w}: when.type=='time' needs at least one of 'at', 'after', or 'every'")
|
|
686
|
+
if has_at and has_after:
|
|
687
|
+
raise TriggerError(f"{w}: when may have at most one of 'at' / 'after'")
|
|
688
|
+
if has_after:
|
|
689
|
+
if not isinstance(when["after"], str) or not when["after"]:
|
|
690
|
+
raise TriggerError(f"{w}: when.after must be a non-empty trigger id")
|
|
691
|
+
if not isinstance(when.get("offset"), str) or not when["offset"]:
|
|
692
|
+
raise TriggerError(f"{w}: when.after requires a non-empty 'offset' duration")
|
|
693
|
+
_dur_or_400(when["offset"], f"{w}: offset")
|
|
694
|
+
elif "offset" in when:
|
|
695
|
+
raise TriggerError(f"{w}: 'offset' is only valid with 'after'")
|
|
696
|
+
if has_at:
|
|
697
|
+
_mark_or_400(when["at"], f"{w}: at")
|
|
698
|
+
is_stochastic = has_every and isinstance(when["every"], dict)
|
|
699
|
+
if has_every:
|
|
700
|
+
every = when["every"]
|
|
701
|
+
if isinstance(every, str):
|
|
702
|
+
if _dur_or_400(every, f"{w}: every") <= timedelta(0):
|
|
703
|
+
raise TriggerError(f"{w}: every must be a positive duration")
|
|
704
|
+
elif isinstance(every, dict):
|
|
705
|
+
if every.get("dist") != "exp":
|
|
706
|
+
raise TriggerError(f"{w}: every.dist must be 'exp'")
|
|
707
|
+
if not isinstance(every.get("mean"), str) or _dur_or_400(every["mean"], f"{w}: every.mean") <= timedelta(0):
|
|
708
|
+
raise TriggerError(f"{w}: every.mean must be a positive ISO-8601 duration")
|
|
709
|
+
if not isinstance(when.get("seed"), int) or isinstance(when.get("seed"), bool):
|
|
710
|
+
raise TriggerError(f"{w}: a stochastic 'every' requires an integer 'seed'")
|
|
711
|
+
else:
|
|
712
|
+
raise TriggerError(f"{w}: every must be a duration string or {{dist, mean}}")
|
|
713
|
+
if "seed" in when and not is_stochastic:
|
|
714
|
+
raise TriggerError(f"{w}: 'seed' is only valid with a stochastic 'every'")
|
|
715
|
+
for term in ("until", "count"):
|
|
716
|
+
if term in when and not has_every:
|
|
717
|
+
raise TriggerError(f"{w}: '{term}' requires a recurring 'every'")
|
|
718
|
+
if "count" in when and (not isinstance(when["count"], int) or isinstance(when["count"], bool) or when["count"] <= 0):
|
|
719
|
+
raise TriggerError(f"{w}: count must be a positive integer")
|
|
720
|
+
if "until" in when:
|
|
721
|
+
_mark_or_400(when["until"], f"{w}: until")
|
|
722
|
+
|
|
723
|
+
def state(self) -> dict:
|
|
724
|
+
events = self._events + list(self._events_tail)
|
|
725
|
+
for t in self._triggers.values():
|
|
726
|
+
assert t["status"] in TRIGGER_STATUSES, f"unknown trigger status {t['status']!r}"
|
|
727
|
+
return {
|
|
728
|
+
"config": {"watch_roles": sorted(self._watch_roles),
|
|
729
|
+
"executor_configured": self._executor is not None},
|
|
730
|
+
"triggers": [
|
|
731
|
+
{"id": tid, "type": t["spec"]["when"]["type"],
|
|
732
|
+
"when": {k: t["spec"]["when"][k] for k in _WHEN_SUMMARY_KEYS if k in t["spec"]["when"]},
|
|
733
|
+
"status": t["status"], "detected_at": t["detected_at"],
|
|
734
|
+
"fired_at": t["fired_at"], "fire_count": t["fire_count"],
|
|
735
|
+
"failure_count": t["failure_count"], "last_failure_at": t["last_failure_at"],
|
|
736
|
+
"barrier": t["spec"].get("barrier"),
|
|
737
|
+
"pending": len(t["pending"]),
|
|
738
|
+
"next_mark": _iso(t["next_mark"]) if t["next_mark"] else None}
|
|
739
|
+
for tid, t in self._triggers.items()
|
|
740
|
+
],
|
|
741
|
+
"events": events,
|
|
742
|
+
"events_dropped": max(0, self._seq - len(events)),
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
def on_tool_call(self, role: str, name: str, arguments: dict,
|
|
746
|
+
result: Any) -> tuple[list[asyncio.Task], float | None]:
|
|
747
|
+
"""Synchronous post-call detection hook (invoked from the gateway's role-filter wrapper).
|
|
748
|
+
|
|
749
|
+
Returns the fire tasks of triggers whose barrier waits at `provoking_call`, and how long to
|
|
750
|
+
hold the call for: the widest of their timeouts, counting an omitted one as the gateway
|
|
751
|
+
default so a neighbour's tighter ask cannot cut it short.
|
|
752
|
+
"""
|
|
753
|
+
pending: list[asyncio.Task] = []
|
|
754
|
+
timeout_s: float | None = None
|
|
755
|
+
if role == self._executor_role: # executor's own traffic is never watched (prevents cascades)
|
|
756
|
+
return pending, timeout_s
|
|
757
|
+
if role not in self._watch_roles or not self._triggers:
|
|
758
|
+
return pending, timeout_s
|
|
759
|
+
# Before the isError guard: an errored call is still a clock tick.
|
|
760
|
+
if self._has_time_triggers:
|
|
761
|
+
self._eval_time_triggers()
|
|
762
|
+
# Handled refusals arrive flagged too: BaseService.flag_handled_errors sets isError at the wire.
|
|
763
|
+
if getattr(result, "isError", False):
|
|
764
|
+
return pending, timeout_s
|
|
765
|
+
data = _result_data(result)
|
|
766
|
+
ctx = {"args": arguments, "result": data}
|
|
767
|
+
provoking = {"tool": name, "role": role}
|
|
768
|
+
for tid, trig in self._triggers.items():
|
|
769
|
+
when = trig["spec"]["when"]
|
|
770
|
+
if when["type"] != "action" or when["tool"] != name:
|
|
771
|
+
continue
|
|
772
|
+
if trig["status"] != "armed" and not (when.get("repeat") and trig["status"] in TRIGGER_IN_FLIGHT_STATUSES):
|
|
773
|
+
continue
|
|
774
|
+
if not all(_eval_predicate(pred, _where_value(ctx, field))
|
|
775
|
+
for field, pred in when.get("where", {}).items()):
|
|
776
|
+
continue
|
|
777
|
+
task = self._mark_detected(tid, trig, provoking, ctx)
|
|
778
|
+
barrier = trig["spec"].get("barrier") or {}
|
|
779
|
+
if task is not None and barrier.get("at") == "provoking_call":
|
|
780
|
+
pending.append(task)
|
|
781
|
+
asked = barrier.get("timeout_seconds") or self._gw.TRIGGER_BARRIER_TIMEOUT_S
|
|
782
|
+
timeout_s = asked if timeout_s is None else max(timeout_s, asked)
|
|
783
|
+
if not self._is_readonly(name):
|
|
784
|
+
for tid, trig in self._triggers.items():
|
|
785
|
+
if trig["status"] == "armed" and trig["spec"]["when"]["type"] == "state" and not trig["evaluating"]:
|
|
786
|
+
trig["evaluating"] = True
|
|
787
|
+
self._schedule(self._eval_state(tid, trig, provoking))
|
|
788
|
+
return pending, timeout_s
|
|
789
|
+
|
|
790
|
+
def _mark_detected(self, tid: str, trig: dict, provoking: dict, ctx: dict | None = None) -> asyncio.Task | None:
|
|
791
|
+
ctx = ctx if ctx is not None else {}
|
|
792
|
+
if trig["status"] in TRIGGER_IN_FLIGHT_STATUSES:
|
|
793
|
+
# Mid-fire: queue it; the running fire drains the queue before it settles.
|
|
794
|
+
trig["pending"].append(ctx)
|
|
795
|
+
trig["status"] = "queued"
|
|
796
|
+
self._emit("detected", tid, provoking=provoking, queued=len(trig["pending"]))
|
|
797
|
+
return trig["task"]
|
|
798
|
+
trig["status"] = "firing"
|
|
799
|
+
trig["detected_at"] = _now()
|
|
800
|
+
self._emit("detected", tid, provoking=provoking)
|
|
801
|
+
trig["task"] = self._schedule(self._fire(tid, trig, ctx))
|
|
802
|
+
trig["task"].set_name(tid)
|
|
803
|
+
return trig["task"]
|
|
804
|
+
|
|
805
|
+
def _eval_time_triggers(self) -> None:
|
|
806
|
+
"""Poll armed time-triggers against the virtual clock and schedule the due ones."""
|
|
807
|
+
clock = self._gw._clock
|
|
808
|
+
now = clock.now()
|
|
809
|
+
if now is None: # clock unarmed -> nothing can be due yet
|
|
810
|
+
return
|
|
811
|
+
gen = clock.generation
|
|
812
|
+
for tid, trig in list(self._triggers.items()):
|
|
813
|
+
when = trig["spec"]["when"]
|
|
814
|
+
if when["type"] != "time" or trig["status"] != "armed":
|
|
815
|
+
continue
|
|
816
|
+
# Re-anchor to the new t0 across a clock re-arm, per-trigger so a mid-fire one isn't skipped.
|
|
817
|
+
# An event-anchored mark is an absolute instant its fire-once anchor can't re-derive -> kept.
|
|
818
|
+
if "after" not in when and trig["resolved"] and trig["mark_gen"] != gen:
|
|
819
|
+
# The old mark (and any backlog behind it) is on a timeline the new clock never reached.
|
|
820
|
+
self._emit("reanchored", tid, generation=gen,
|
|
821
|
+
dropped_mark=_iso(trig["next_mark"]) if trig["next_mark"] else None)
|
|
822
|
+
trig["resolved"] = False
|
|
823
|
+
trig["next_mark"] = None
|
|
824
|
+
trig["rng"] = _make_rng(trig["spec"])
|
|
825
|
+
if not trig["resolved"]:
|
|
826
|
+
self._resolve_first_mark(trig, now)
|
|
827
|
+
if trig["resolved"]:
|
|
828
|
+
trig["mark_gen"] = gen
|
|
829
|
+
mark = trig["next_mark"]
|
|
830
|
+
if mark is None or now < mark:
|
|
831
|
+
continue
|
|
832
|
+
marks, capped, terminated = self._advance_marks(trig, now)
|
|
833
|
+
if marks:
|
|
834
|
+
# The cause is time passing, not whichever path observed it.
|
|
835
|
+
self._mark_time_detected(tid, trig, marks, capped, terminated, {"source": "clock"})
|
|
836
|
+
elif terminated: # retired without firing (e.g. `until` already passed)
|
|
837
|
+
trig["status"] = "fired"
|
|
838
|
+
trig["next_mark"] = None
|
|
839
|
+
self._recompute_time_flag()
|
|
840
|
+
|
|
841
|
+
def _resolve_first_mark(self, trig: dict, now: datetime) -> None:
|
|
842
|
+
"""Stamp the first mark for a non-anchored time-trigger; event-anchored ones wait for _resolve_dependents."""
|
|
843
|
+
when = trig["spec"]["when"]
|
|
844
|
+
if "after" in when:
|
|
845
|
+
return
|
|
846
|
+
t0 = self._gw._clock.t0()
|
|
847
|
+
if "at" in when:
|
|
848
|
+
kind, val = _time_mark_spec(when["at"])
|
|
849
|
+
start = val if kind == "abs" else _advance(t0, val.total_seconds())
|
|
850
|
+
else: # recurring with no explicit start: first fire is one interval after t0
|
|
851
|
+
start = _advance(t0, self._next_interval_seconds(trig))
|
|
852
|
+
trig["next_mark"] = start
|
|
853
|
+
trig["resolved"] = True
|
|
854
|
+
|
|
855
|
+
def _resolve_dependents(self, anchor_tid: str) -> None:
|
|
856
|
+
"""Stamp the concrete mark (virtual_now + offset) of every time-trigger anchored on `anchor_tid`."""
|
|
857
|
+
# Safe despite _fire_time recomputing the flag first: an unresolved `after` dependent is itself
|
|
858
|
+
# an armed time trigger, so the flag stays set for exactly as long as one can be stranded.
|
|
859
|
+
if not self._has_time_triggers:
|
|
860
|
+
return
|
|
861
|
+
now = self._gw._clock.now()
|
|
862
|
+
if now is None:
|
|
863
|
+
return
|
|
864
|
+
for tid, trig in self._triggers.items():
|
|
865
|
+
when = trig["spec"]["when"]
|
|
866
|
+
if when["type"] != "time" or when.get("after") != anchor_tid or trig["resolved"]:
|
|
867
|
+
continue
|
|
868
|
+
trig["next_mark"] = _advance(now, _parse_duration(when["offset"]).total_seconds())
|
|
869
|
+
trig["resolved"] = True
|
|
870
|
+
self._emit("anchored", tid, anchor=anchor_tid, mark=_iso(trig["next_mark"]))
|
|
871
|
+
|
|
872
|
+
def _until_dt(self, trig: dict) -> datetime | None:
|
|
873
|
+
when = trig["spec"]["when"]
|
|
874
|
+
if "until" not in when:
|
|
875
|
+
return None
|
|
876
|
+
kind, val = _time_mark_spec(when["until"])
|
|
877
|
+
return val if kind == "abs" else _advance(self._gw._clock.t0(), val.total_seconds())
|
|
878
|
+
|
|
879
|
+
def _next_interval_seconds(self, trig: dict) -> float:
|
|
880
|
+
"""Virtual seconds to the next mark; drawn on the sync path so the sequence depends only on the seed."""
|
|
881
|
+
every = trig["spec"]["when"]["every"]
|
|
882
|
+
if isinstance(every, dict):
|
|
883
|
+
mean_s = _parse_duration(every["mean"]).total_seconds()
|
|
884
|
+
return trig["rng"].expovariate(1.0 / mean_s)
|
|
885
|
+
return _parse_duration(every).total_seconds()
|
|
886
|
+
|
|
887
|
+
def _advance_marks(self, trig: dict, now: datetime) -> tuple[list[datetime], bool, bool]:
|
|
888
|
+
"""SYNC: the marks due at `now` (<= _MAX_CATCHUP), advancing next_mark past them.
|
|
889
|
+
|
|
890
|
+
Returns (due_marks, capped, terminated). A capped backlog drains on later evaluations of the
|
|
891
|
+
same clock generation; a re-arm drops it. Marks are returned so each arrival can record the
|
|
892
|
+
virtual instant it was due at."""
|
|
893
|
+
when = trig["spec"]["when"]
|
|
894
|
+
recurring = "every" in when
|
|
895
|
+
count = when.get("count")
|
|
896
|
+
until = self._until_dt(trig)
|
|
897
|
+
marks: list[datetime] = []
|
|
898
|
+
capped = False
|
|
899
|
+
terminated = False
|
|
900
|
+
while trig["next_mark"] is not None and now >= trig["next_mark"]:
|
|
901
|
+
if until is not None and trig["next_mark"] > until:
|
|
902
|
+
trig["next_mark"] = None
|
|
903
|
+
terminated = True
|
|
904
|
+
break
|
|
905
|
+
marks.append(trig["next_mark"])
|
|
906
|
+
if not recurring:
|
|
907
|
+
trig["next_mark"] = None
|
|
908
|
+
terminated = True
|
|
909
|
+
break
|
|
910
|
+
if count is not None and trig["fire_count"] + len(marks) >= count:
|
|
911
|
+
trig["next_mark"] = None
|
|
912
|
+
terminated = True
|
|
913
|
+
break
|
|
914
|
+
prev_mark = trig["next_mark"]
|
|
915
|
+
trig["next_mark"] = _advance(prev_mark, self._next_interval_seconds(trig))
|
|
916
|
+
if trig["next_mark"] <= prev_mark: # saturated at the datetime ceiling: can't advance -> retire
|
|
917
|
+
trig["next_mark"] = None # (else now==next_mark==max would re-burst _MAX_CATCHUP every call)
|
|
918
|
+
terminated = True
|
|
919
|
+
break
|
|
920
|
+
if len(marks) >= _MAX_CATCHUP:
|
|
921
|
+
capped = True
|
|
922
|
+
break
|
|
923
|
+
return marks, capped, terminated
|
|
924
|
+
|
|
925
|
+
def _mark_time_detected(self, tid: str, trig: dict, marks: list[datetime], capped: bool,
|
|
926
|
+
terminated: bool, provoking: dict) -> None:
|
|
927
|
+
trig["status"] = "firing"
|
|
928
|
+
trig["detected_at"] = _now()
|
|
929
|
+
# first/last only: a burst can carry _MAX_CATCHUP marks, each with its own `fired` event.
|
|
930
|
+
self._emit("detected", tid, provoking=provoking, due=len(marks), capped=capped,
|
|
931
|
+
first_mark=_iso(marks[0]), last_mark=_iso(marks[-1]))
|
|
932
|
+
self._schedule(self._fire_time(tid, trig, marks, capped, terminated))
|
|
933
|
+
|
|
934
|
+
async def _fire_time(self, tid: str, trig: dict, marks: list[datetime], capped: bool, terminated: bool) -> None:
|
|
935
|
+
"""Run the actions once per due mark, then re-arm (recurring) or retire (one-shot / terminated)."""
|
|
936
|
+
try:
|
|
937
|
+
await self._gw._log_event({"event_type": "trigger_fired", "source": "trigger_engine", "trigger_id": tid})
|
|
938
|
+
for mark in marks:
|
|
939
|
+
for index, action in enumerate(trig["spec"]["actions"]):
|
|
940
|
+
if not await self._run_action(tid, index, action, {}):
|
|
941
|
+
trig["status"] = "failed"
|
|
942
|
+
trig["next_mark"] = None
|
|
943
|
+
self._emit("failed", tid, action_index=index)
|
|
944
|
+
return
|
|
945
|
+
trig["fire_count"] += 1
|
|
946
|
+
trig["fired_at"] = _now()
|
|
947
|
+
self._emit("fired", tid, fire_count=trig["fire_count"], mark=_iso(mark), capped=capped)
|
|
948
|
+
trig["status"] = "fired" if terminated else "armed"
|
|
949
|
+
except Exception as e:
|
|
950
|
+
logger.exception(f"time trigger '{tid}' firing failed")
|
|
951
|
+
trig["status"] = "failed"
|
|
952
|
+
trig["next_mark"] = None
|
|
953
|
+
self._emit("failed", tid, detail=str(e)[:300])
|
|
954
|
+
return
|
|
955
|
+
finally:
|
|
956
|
+
self._recompute_time_flag() # a terminated trigger lets the driver park again
|
|
957
|
+
self._resolve_dependents(tid) # success-only, outside the try (see _fire)
|
|
958
|
+
|
|
959
|
+
def invalidate_readonly_cache(self) -> None:
|
|
960
|
+
"""Drop the memoized readOnlyHint set; a stale memo classes a read-only tool as writable."""
|
|
961
|
+
self._readonly_tools = None
|
|
962
|
+
|
|
963
|
+
def _is_readonly(self, name: str) -> bool:
|
|
964
|
+
if self._readonly_tools is None:
|
|
965
|
+
readonly: set[str] = set()
|
|
966
|
+
for tools in self._gw._server_tools.values():
|
|
967
|
+
for tool in tools:
|
|
968
|
+
annotations = getattr(tool, "annotations", None)
|
|
969
|
+
if annotations is not None and getattr(annotations, "readOnlyHint", None) is True:
|
|
970
|
+
readonly.add(tool.name)
|
|
971
|
+
if not self._gw._server_tools:
|
|
972
|
+
return False
|
|
973
|
+
self._readonly_tools = readonly
|
|
974
|
+
return name in self._readonly_tools
|
|
975
|
+
|
|
976
|
+
def _schedule(self, coro) -> asyncio.Task:
|
|
977
|
+
task = asyncio.get_running_loop().create_task(coro)
|
|
978
|
+
self._tasks.add(task)
|
|
979
|
+
task.add_done_callback(self._tasks.discard)
|
|
980
|
+
return task
|
|
981
|
+
|
|
982
|
+
def _emit(self, kind: str, trigger_id: str | None, **payload) -> dict:
|
|
983
|
+
assert kind in _EVENT_KINDS, f"unknown trigger event kind {kind!r}"
|
|
984
|
+
self._seq += 1
|
|
985
|
+
# `ts` is real; a catch-up burst collapses into a few ms of it, hence `virtual_time` too
|
|
986
|
+
# (omitted when unarmed, so a clock-less deployment sees unchanged events).
|
|
987
|
+
virtual = self._gw._clock.now()
|
|
988
|
+
event = {"seq": self._seq, "ts": _now(),
|
|
989
|
+
**({"virtual_time": _iso(virtual)} if virtual is not None else {}),
|
|
990
|
+
"kind": kind, **({"trigger_id": trigger_id} if trigger_id else {}),
|
|
991
|
+
**payload}
|
|
992
|
+
(self._events if len(self._events) < _EVENTS_HEAD else self._events_tail).append(event)
|
|
993
|
+
if kind in _FAILURE_KINDS and trigger_id:
|
|
994
|
+
# A failure no longer parks the status and the event log is capped, so this is the lasting record.
|
|
995
|
+
trig = self._triggers.get(trigger_id)
|
|
996
|
+
if trig is not None:
|
|
997
|
+
trig["failure_count"] += 1
|
|
998
|
+
trig["last_failure_at"] = event["ts"]
|
|
999
|
+
logger.info(f"trigger event: {event}")
|
|
1000
|
+
return event
|
|
1001
|
+
|
|
1002
|
+
async def _eval_state(self, tid: str, trig: dict, provoking: dict) -> None:
|
|
1003
|
+
try:
|
|
1004
|
+
matched = await self._run_check(trig["spec"]["when"]["check"])
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
self._emit("eval_error", tid, detail=str(e)[:300])
|
|
1007
|
+
matched = False
|
|
1008
|
+
finally:
|
|
1009
|
+
trig["evaluating"] = False
|
|
1010
|
+
if matched and trig["status"] == "armed":
|
|
1011
|
+
self._mark_detected(tid, trig, provoking)
|
|
1012
|
+
|
|
1013
|
+
async def _run_check(self, check: dict, ctx: dict | None = None) -> bool:
|
|
1014
|
+
bindings: dict[str, Any] = {}
|
|
1015
|
+
for step in check["steps"]:
|
|
1016
|
+
result = await self._internal_call(step["tool"], _template(step.get("args", {}), bindings, ctx))
|
|
1017
|
+
if getattr(result, "isError", False):
|
|
1018
|
+
return False
|
|
1019
|
+
raw_text = "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text")
|
|
1020
|
+
try:
|
|
1021
|
+
data = json.loads(raw_text)
|
|
1022
|
+
except (ValueError, TypeError):
|
|
1023
|
+
data = None
|
|
1024
|
+
value = _apply_extract(step.get("extract"), data, raw_text)
|
|
1025
|
+
bind = step.get("bind")
|
|
1026
|
+
if bind is not None:
|
|
1027
|
+
if value in (None, "", []):
|
|
1028
|
+
return False
|
|
1029
|
+
bindings[bind] = value
|
|
1030
|
+
if "predicate" in step and not _eval_predicate(step["predicate"], value):
|
|
1031
|
+
return False
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
async def _internal_call(self, tool_name: str, arguments: dict) -> CallToolResult:
|
|
1035
|
+
gw = self._gw
|
|
1036
|
+
await gw._ensure_tools_discovered()
|
|
1037
|
+
server_url = gw._tool_server_urls.get(tool_name)
|
|
1038
|
+
if server_url is None:
|
|
1039
|
+
tool = gw._mcp._tool_manager._tools.get(tool_name)
|
|
1040
|
+
if tool is None:
|
|
1041
|
+
raise RuntimeError(f"unknown tool {tool_name!r}")
|
|
1042
|
+
result = await tool.run(arguments)
|
|
1043
|
+
if isinstance(result, CallToolResult):
|
|
1044
|
+
return result
|
|
1045
|
+
text = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
1046
|
+
return CallToolResult(content=[TextContent(type="text", text=text)], isError=False)
|
|
1047
|
+
session = await gw._get_step_session(server_url)
|
|
1048
|
+
return await asyncio.wait_for(session.call_tool(tool_name, arguments), timeout=gw.TOOL_CALL_TIMEOUT_S)
|
|
1049
|
+
|
|
1050
|
+
async def _fire(self, tid: str, trig: dict, ctx: dict) -> None:
|
|
1051
|
+
"""Run the actions for one provoking call, then drain any calls queued while firing (repeat
|
|
1052
|
+
triggers). A failure always re-arms: a mirror states a standing invariant, so one transient
|
|
1053
|
+
error must not silence it for the rest of the run. Ends armed or fired (one-shot success)."""
|
|
1054
|
+
spec = trig["spec"]
|
|
1055
|
+
repeat = bool(spec["when"].get("repeat"))
|
|
1056
|
+
try:
|
|
1057
|
+
while True:
|
|
1058
|
+
ok = True
|
|
1059
|
+
try:
|
|
1060
|
+
await self._gw._log_event({"event_type": "trigger_fired", "source": "trigger_engine", "trigger_id": tid,
|
|
1061
|
+
"provoking_tool": (ctx.get("args") is not None and spec["when"].get("tool")) or None})
|
|
1062
|
+
for index, action in enumerate(spec["actions"]):
|
|
1063
|
+
if not await self._run_action(tid, index, action, ctx):
|
|
1064
|
+
ok = False
|
|
1065
|
+
self._emit("failed", tid, action_index=index)
|
|
1066
|
+
break
|
|
1067
|
+
if ok:
|
|
1068
|
+
trig["fire_count"] += 1
|
|
1069
|
+
trig["fired_at"] = _now()
|
|
1070
|
+
self._emit("fired", tid, fire_count=trig["fire_count"])
|
|
1071
|
+
except Exception as e:
|
|
1072
|
+
logger.exception(f"trigger '{tid}' firing failed")
|
|
1073
|
+
ok = False
|
|
1074
|
+
self._emit("failed", tid, detail=str(e)[:300])
|
|
1075
|
+
if ok:
|
|
1076
|
+
self._resolve_dependents(tid) # success-only, outside the try so it can't flip a fired trigger to failed
|
|
1077
|
+
if trig["pending"]:
|
|
1078
|
+
ctx = trig["pending"].popleft()
|
|
1079
|
+
trig["status"] = "firing"
|
|
1080
|
+
trig["detected_at"] = _now()
|
|
1081
|
+
continue
|
|
1082
|
+
trig["status"] = "armed" if (repeat or not ok) else "fired"
|
|
1083
|
+
return
|
|
1084
|
+
finally:
|
|
1085
|
+
# An abnormal exit must not wedge the trigger in firing/queued, nor drop its queue silently.
|
|
1086
|
+
if trig["status"] in TRIGGER_IN_FLIGHT_STATUSES:
|
|
1087
|
+
self._emit("failed", tid, detail="firing did not settle", dropped=len(trig["pending"]))
|
|
1088
|
+
trig["status"] = "armed"
|
|
1089
|
+
trig["pending"].clear()
|
|
1090
|
+
trig["task"] = None
|
|
1091
|
+
|
|
1092
|
+
async def _run_action(self, tid: str, index: int, action: dict, ctx: dict) -> bool:
|
|
1093
|
+
changelog_before = self._gw._query_changelog_id()
|
|
1094
|
+
try:
|
|
1095
|
+
if action["type"] == "permission":
|
|
1096
|
+
tools = action["tools"] if isinstance(action["tools"], list) else [action["tools"]]
|
|
1097
|
+
async with self._gw._role_rules_lock:
|
|
1098
|
+
for tool in tools:
|
|
1099
|
+
self._gw._apply_rule(action["role"], tool, action["action"] == "disable")
|
|
1100
|
+
detail = {"role": action["role"], "action": action["action"], "tools": tools}
|
|
1101
|
+
elif action["type"] == "tool":
|
|
1102
|
+
try:
|
|
1103
|
+
args = _template(action.get("args", {}), {}, ctx)
|
|
1104
|
+
except TemplateError as e:
|
|
1105
|
+
self._emit("action_failed", tid, action_index=index, detail=str(e)[:300], tool=action["tool"])
|
|
1106
|
+
return False
|
|
1107
|
+
result = await self._internal_call(action["tool"], args)
|
|
1108
|
+
text = _result_text(result)
|
|
1109
|
+
failed = bool(getattr(result, "isError", False))
|
|
1110
|
+
# The write itself, not just the trigger_fired marker: a Harbor grader has no timeline.
|
|
1111
|
+
await self._gw._log_event({
|
|
1112
|
+
"event_type": "internal_tool_call", "source": "trigger_engine", "trigger_id": tid,
|
|
1113
|
+
"action_index": index, "tool": action["tool"], "arguments": _echo_args(args),
|
|
1114
|
+
"ok": not failed, "result_len": len(text),
|
|
1115
|
+
"result_sha256": hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:_ECHO_DIGEST_CHARS],
|
|
1116
|
+
"changelog_id_before": changelog_before, "changelog_id_after": self._gw._query_changelog_id()})
|
|
1117
|
+
if failed:
|
|
1118
|
+
# The refusal text is the only account of why a mirror broke; counters cannot replace it.
|
|
1119
|
+
self._emit("action_failed", tid, action_index=index, detail=text[:300],
|
|
1120
|
+
tool=action["tool"], args=_echo_args(args))
|
|
1121
|
+
return False
|
|
1122
|
+
detail = {"tool": action["tool"], "args": _echo_args(args)}
|
|
1123
|
+
else:
|
|
1124
|
+
if not await self._run_nl(tid, index, action, ctx):
|
|
1125
|
+
return False
|
|
1126
|
+
detail = {"nl": action["instruction"][:120]}
|
|
1127
|
+
if "verify" in action and action["type"] != "nl":
|
|
1128
|
+
if not await self._run_verify(tid, index, action["verify"], ctx):
|
|
1129
|
+
return False
|
|
1130
|
+
self._emit("action_ok", tid, action_index=index, detail=detail,
|
|
1131
|
+
changelog_id_before=changelog_before, changelog_id_after=self._gw._query_changelog_id())
|
|
1132
|
+
return True
|
|
1133
|
+
except Exception as e:
|
|
1134
|
+
self._emit("action_failed", tid, action_index=index, detail=str(e)[:300])
|
|
1135
|
+
return False
|
|
1136
|
+
|
|
1137
|
+
async def _run_verify(self, tid: str, index: int, verify: dict, ctx: dict | None = None) -> bool:
|
|
1138
|
+
if await self._run_check(verify, ctx):
|
|
1139
|
+
self._emit("verify_ok", tid, action_index=index)
|
|
1140
|
+
return True
|
|
1141
|
+
self._emit("verify_failed", tid, action_index=index)
|
|
1142
|
+
return False
|
|
1143
|
+
|
|
1144
|
+
async def _run_nl(self, tid: str, index: int, action: dict, ctx: dict | None = None) -> bool:
|
|
1145
|
+
instruction = action["instruction"]
|
|
1146
|
+
for attempt in (1, 2):
|
|
1147
|
+
reply = await self._call_executor(instruction, context_id=f"trigger-{tid}")
|
|
1148
|
+
if reply is None:
|
|
1149
|
+
self._emit("action_failed", tid, action_index=index, detail=f"executor attempt {attempt} did not complete")
|
|
1150
|
+
return False
|
|
1151
|
+
if "verify" not in action:
|
|
1152
|
+
return True
|
|
1153
|
+
if await self._run_verify(tid, index, action["verify"], ctx):
|
|
1154
|
+
return True
|
|
1155
|
+
if attempt == 1:
|
|
1156
|
+
instruction = (f"{action['instruction']}\n\nYour previous attempt did not pass verification — "
|
|
1157
|
+
f"the expected change is not observable in the environment. Do it again exactly as described.")
|
|
1158
|
+
self._emit("action_failed", tid, action_index=index, detail="verification failed after retry")
|
|
1159
|
+
return False
|
|
1160
|
+
|
|
1161
|
+
async def _call_executor(self, instruction: str, context_id: str) -> str | None:
|
|
1162
|
+
executor = self._executor
|
|
1163
|
+
if executor is None:
|
|
1164
|
+
return None
|
|
1165
|
+
url = f"{executor['a2a_url']}/a2a"
|
|
1166
|
+
deadline = asyncio.get_running_loop().time() + executor["timeout_seconds"]
|
|
1167
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
1168
|
+
send = await client.post(url, json={
|
|
1169
|
+
"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "message/send",
|
|
1170
|
+
"params": {"message": {"role": "user", "messageId": uuid.uuid4().hex,
|
|
1171
|
+
"contextId": context_id,
|
|
1172
|
+
"parts": [{"kind": "text", "text": instruction}]}},
|
|
1173
|
+
})
|
|
1174
|
+
send.raise_for_status()
|
|
1175
|
+
task_id = (send.json().get("result") or {}).get("id")
|
|
1176
|
+
if not task_id:
|
|
1177
|
+
return None
|
|
1178
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
1179
|
+
await asyncio.sleep(2)
|
|
1180
|
+
poll = await client.post(url, json={"jsonrpc": "2.0", "id": str(uuid.uuid4()),
|
|
1181
|
+
"method": "tasks/get", "params": {"id": task_id}})
|
|
1182
|
+
poll.raise_for_status()
|
|
1183
|
+
status = ((poll.json().get("result") or {}).get("status") or {})
|
|
1184
|
+
if status.get("state") in _TERMINAL_A2A_STATES:
|
|
1185
|
+
if status.get("state") != "completed":
|
|
1186
|
+
return None
|
|
1187
|
+
parts = (status.get("message") or {}).get("parts") or []
|
|
1188
|
+
return "\n".join(p.get("text", "") for p in parts if p.get("kind") == "text")
|
|
1189
|
+
return None
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _result_text(result: CallToolResult) -> str:
|
|
1193
|
+
return "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text")
|