mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
2812 lines
121 KiB
Python
2812 lines
121 KiB
Python
"""Enforcement wrapper — nudge agent when it skips required steps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import re
|
|
import time
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlparse
|
|
|
|
import structlog
|
|
from agents import ModelSettings, RunConfig
|
|
from agents.run import Runner
|
|
|
|
from skyvern.forge.sdk.copilot import config as copilot_config_defaults
|
|
from skyvern.forge.sdk.copilot.blocker_signal import (
|
|
SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE,
|
|
UNCOVERED_OUTPUT_RESCOUT_STEER_REASON_CODE,
|
|
CopilotToolBlockerSignal,
|
|
clear_tool_blocker_signals_for_reason_codes,
|
|
compose_loop_blocker_user_facing_reason,
|
|
loop_blocker_evidence_from_ctx,
|
|
stash_blocker_signal,
|
|
)
|
|
from skyvern.forge.sdk.copilot.build_phase import DISCOVERY_PERMITTED_PHASES
|
|
from skyvern.forge.sdk.copilot.build_test_outcome import (
|
|
RecordedBuildTestOutcome,
|
|
author_time_reject_missing_output_paths,
|
|
run_backed_repair_evidence_exists,
|
|
)
|
|
from skyvern.forge.sdk.copilot.code_block_synthesis import (
|
|
is_durable_fallback_entry_target,
|
|
is_generic_entry_opener_click,
|
|
is_optional_dismissal_only_trajectory,
|
|
render_synthesized_offer_text,
|
|
synthesize_code_block,
|
|
trajectory_has_browser_fill_interaction,
|
|
)
|
|
from skyvern.forge.sdk.copilot.completion_criteria_store import requested_output_paths
|
|
from skyvern.forge.sdk.copilot.completion_verification import only_structural_requested_output_abstentions
|
|
from skyvern.forge.sdk.copilot.composition_evidence import interactive_challenge_controls
|
|
from skyvern.forge.sdk.copilot.config import (
|
|
DEFAULT_ENFORCEMENT_NUDGES,
|
|
DEFAULT_TOKEN_BUDGET,
|
|
POST_ANTI_BOT_FAILED_TEST_NUDGE,
|
|
POST_DISCOVERY_ENTRYPOINT_URL_QUESTION_NUDGE,
|
|
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE,
|
|
POST_FAILED_TEST_INSPECT_FIRST_NUDGE,
|
|
POST_FAILED_TEST_NUDGE,
|
|
POST_NAVIGATE_NUDGE,
|
|
POST_NO_WORKFLOW_DELIVERY_NUDGE,
|
|
POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE,
|
|
POST_PARAMETER_BINDING_STOP_NUDGE,
|
|
POST_PARAMETER_BINDING_WARN_NUDGE,
|
|
POST_PER_TOOL_BUDGET_NUDGE,
|
|
POST_PER_TOOL_BUDGET_STOP_NUDGE,
|
|
POST_PROBABLE_SITE_BLOCK_STOP_NUDGE,
|
|
POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE,
|
|
POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE,
|
|
POST_SUSPICIOUS_SUCCESS_NUDGE,
|
|
POST_UPDATE_NUDGE,
|
|
PRE_DISCOVERY_URL_QUESTION_NUDGE,
|
|
SCREENSHOT_DROPPED_NUDGE,
|
|
SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD,
|
|
BlockAuthoringPolicy,
|
|
CopilotConfig,
|
|
normalize_block_authoring_policy,
|
|
)
|
|
from skyvern.forge.sdk.copilot.diagnosis_repair_contract import (
|
|
DiagnosisRepairContract,
|
|
RepairLoopState,
|
|
RepairNextAction,
|
|
)
|
|
from skyvern.forge.sdk.copilot.failure_tracking import PER_TOOL_BUDGET_FAILURE_CATEGORY, normalize_failure_reason
|
|
from skyvern.forge.sdk.copilot.narration import TransitionKind
|
|
from skyvern.forge.sdk.copilot.output_contracts import OutputContractAdvisoryState
|
|
from skyvern.forge.sdk.copilot.output_policy import (
|
|
completion_criterion_requires_browser_fill_delivery,
|
|
normalize_response_scaffolding,
|
|
)
|
|
from skyvern.forge.sdk.copilot.output_utils import (
|
|
extract_final_text,
|
|
looks_like_workflow_delivery_claim,
|
|
parse_final_response,
|
|
)
|
|
from skyvern.forge.sdk.copilot.reached_download_target import ReachedDownloadTarget
|
|
from skyvern.forge.sdk.copilot.request_policy import (
|
|
REGISTERED_DOWNLOAD_REQUESTED_OUTPUT_PATHS,
|
|
CompletionCriterion,
|
|
RequestPolicy,
|
|
request_policy_has_present_completion_contract,
|
|
requested_output_path_for_field,
|
|
schema_output_path_aliases_from_criteria,
|
|
)
|
|
from skyvern.forge.sdk.copilot.result_evidence import (
|
|
COVERAGE_TOKEN_RE,
|
|
covered_output_paths_in_result_containers,
|
|
)
|
|
from skyvern.forge.sdk.copilot.run_outcome import (
|
|
TERMINAL_CHALLENGE_BLOCKER_REASON_CODE,
|
|
TERMINAL_CHALLENGE_RUN_OUTCOME_REASON_CODE,
|
|
TERMINAL_CHALLENGE_USER_FACING_REASON,
|
|
RecordedRunOutcome,
|
|
run_outcome_display_reason,
|
|
)
|
|
from skyvern.forge.sdk.copilot.runtime import AuthorTimeGateAblationPayload, record_author_time_gate_ablation_event
|
|
from skyvern.forge.sdk.copilot.screenshot_utils import ScreenshotEntry
|
|
from skyvern.forge.sdk.copilot.terminal_predicates import (
|
|
artifact_health_blocked,
|
|
outcome_criteria_evaluated,
|
|
outcome_fully_verified,
|
|
)
|
|
from skyvern.forge.sdk.copilot.tracing_setup import copilot_span
|
|
from skyvern.forge.sdk.copilot.turn_halt import (
|
|
blocker_signal_is_genuinely_terminal,
|
|
raise_if_turn_halt,
|
|
stash_repair_ceiling_turn_halt,
|
|
stash_turn_halt_from_blocker_signal,
|
|
)
|
|
from skyvern.forge.sdk.copilot.turn_intent import RequiredContextKey, TurnIntent, TurnIntentMode
|
|
from skyvern.utils.token_counter import count_tokens
|
|
|
|
if TYPE_CHECKING:
|
|
from agents.agent import Agent
|
|
from agents.result import RunResultStreaming
|
|
|
|
from skyvern.forge.sdk.copilot.context import CopilotContext
|
|
from skyvern.forge.sdk.copilot.runtime import AgentContext
|
|
from skyvern.forge.sdk.routes.event_source_stream import EventSourceStream
|
|
|
|
LOG = structlog.get_logger()
|
|
|
|
POST_FORMAT_NUDGE = copilot_config_defaults.POST_FORMAT_NUDGE
|
|
POST_INTERMEDIATE_SUCCESS_NUDGE = copilot_config_defaults.POST_INTERMEDIATE_SUCCESS_NUDGE
|
|
|
|
MAX_POST_UPDATE_NUDGES = 2
|
|
MAX_INTERMEDIATE_NUDGES = 8
|
|
MAX_FAILED_TEST_NUDGES = 2
|
|
MAX_FORMAT_NUDGES = 2
|
|
MAX_NO_WORKFLOW_NUDGES = 2
|
|
MAX_DISCOVERY_ENTRYPOINT_URL_QUESTION_NUDGES = 2
|
|
MAX_PRE_DISCOVERY_URL_QUESTION_NUDGES = 2
|
|
MAX_EXPLORE_WITHOUT_WORKFLOW_NUDGES = 2
|
|
# Stops the suspicious-success nudge from re-firing forever when the agent has
|
|
# correctly diagnosed an unrecoverable block (anti-bot, paywall) and is no
|
|
# longer willing to re-run extraction.
|
|
MAX_SUSPICIOUS_SUCCESS_NUDGES = 2
|
|
# Streak levels for repeated-failure (same frontier + same failure signature).
|
|
REPEATED_FRONTIER_STREAK_ESCALATE_AT = 2
|
|
REPEATED_FRONTIER_STREAK_STOP_AT = 3
|
|
# Stop after this many consecutive runs where navigation succeeded but the
|
|
# scraper could not read the page. Aligned with MAX_FAILED_TEST_NUDGES so the
|
|
# copilot gets one generic retry nudge, then stops on the second occurrence.
|
|
PROBABLE_SITE_BLOCK_STREAK_STOP_AT = 2
|
|
UNRECOVERABLE_TOOL_ERROR_STOP_AT = 2
|
|
# Caps how many times the stop nudge can re-fire — without this, the streak
|
|
# stays latched while no new test runs reset it and every subsequent turn
|
|
# re-injects the same nudge until MAX_ITERATIONS. Independent of
|
|
# PROBABLE_SITE_BLOCK_STREAK_STOP_AT (both default to 2 but tune different
|
|
# axes: streak depth vs nudge count).
|
|
MAX_PROBABLE_SITE_BLOCK_STOP_NUDGES = 2
|
|
# Caps how many times the per-tool-budget split nudge can fire. After two
|
|
# trips the agent should already be at single-block granularity; further
|
|
# trips fall through to the repeated-frontier escalation path.
|
|
MAX_PER_TOOL_BUDGET_NUDGES = 2
|
|
# Code-authoring guardrail churn: distinct-reject convergence backstop. An
|
|
# accepted persist resets the counter, so this many unaccepted rejections is
|
|
# pathological and leaves three free repair attempts before the halt fires —
|
|
# far inside both the 900s budget and the SDK max-turns cap.
|
|
MAX_CODE_AUTHORING_GUARDRAIL_REJECTS = 4
|
|
# Credential-priority rejects defer to the credential-scout message until this
|
|
# higher bound, which must stay above MAX_CODE_AUTHORING_GUARDRAIL_REJECTS so the
|
|
# non-credential backstop and the low-count credential deferral are untouched.
|
|
MAX_CREDENTIAL_PRIORITY_AUTHORING_REJECTS = 8
|
|
# Far under the SDK max-turns cap so the halt arrives first.
|
|
MAX_NO_PROGRESS_INTERACTION_ATTEMPTS = 4
|
|
_NO_PROGRESS_INTERACTION_REASON_CODES = frozenset({"loop_detected_no_forward_progress_interaction"})
|
|
MIN_BLOCKS_FOR_AUTO_COMPLETE = 10
|
|
TOTAL_TIMEOUT_SECONDS = 900
|
|
# Belt-and-braces cap alongside the elapsed-time budget. Per-nudge caps
|
|
# already prevent individual branches from looping; this stops a brand-new
|
|
# enforcement rule that forgets its own counter from spinning within 900s.
|
|
MAX_ITERATIONS = 50
|
|
SCREENSHOT_SENTINEL = "[copilot:screenshot] "
|
|
NUDGE_SENTINEL = "[copilot:nudge] "
|
|
SCREENSHOT_PLACEHOLDER = SCREENSHOT_SENTINEL + "[prior screenshot removed to save context]"
|
|
TOKEN_BUDGET = DEFAULT_TOKEN_BUDGET
|
|
SYNTHESIZED_BLOCK_PERSISTENCE_TOOL = "update_and_run_blocks"
|
|
_SYNTHESIZED_BLOCK_PERSISTENCE_ALLOWED_TOOLS = frozenset(
|
|
{SYNTHESIZED_BLOCK_PERSISTENCE_TOOL, "fill_credential_field", "update_workflow"}
|
|
)
|
|
_ACTUATION_OBLIGATION_REQUIRED_FILL_TOOL = "type_text"
|
|
_SYNTHESIZED_BLOCK_PERSISTENCE_MUTATING_TOOLS = frozenset(
|
|
{"click", "press_key", "type_text", "select_option", "navigate_browser"}
|
|
)
|
|
# Both tools re-author the workflow draft and clear the coverage-reopen flag; the steer must fire
|
|
# for either or an update_workflow re-author silently spends the one-shot rescout.
|
|
_SYNTHESIZED_BLOCK_REAUTHORING_TOOLS = frozenset({SYNTHESIZED_BLOCK_PERSISTENCE_TOOL, "update_workflow"})
|
|
_SYNTHESIZED_BLOCK_COMMIT_TOOLS = frozenset({"click", "press_key"})
|
|
# Evidence sources confirmable only after a run — excluded from the pre-run scout-coverage gate.
|
|
_PRE_RUN_UNGATED_EVIDENCE_SOURCES = frozenset({"registered_output_parameter", "registered_artifact_content"})
|
|
# OpenAI detail=high cost per resized image. If we support other providers,
|
|
# pull from model config — this value will silently over/undercount otherwise.
|
|
# See screenshot_utils.resize_screenshot_b64 for the dimension contract this
|
|
# token count assumes.
|
|
TOKENS_PER_RESIZED_IMAGE = 765
|
|
|
|
# Keep the last N function_call_output items at full (head-truncated) size.
|
|
# Older outputs collapse to a compact synopsis so context doesn't grow linearly.
|
|
KEEP_RECENT_TOOL_OUTPUTS = 3
|
|
_RECENT_TOOL_OUTPUT_CHAR_CAP = 2000
|
|
_TOOL_OUTPUT_SUMMARIZE_THRESHOLD = 300
|
|
_TOOL_OUTPUT_TRUNCATION_SUFFIX = "\n... [older tool output truncated]"
|
|
# Head-truncation marker for the recent tool-output window. Kept on a
|
|
# module-level constant so session_factory can import the same string and
|
|
# the two paths stay in sync if the wording ever changes.
|
|
_TOOL_OUTPUT_HEAD_TRUNCATION_SUFFIX = "\n... [truncated]"
|
|
|
|
# A REPLY matching any of these is almost certainly the agent leaking internal
|
|
# iteration state instead of finalizing or asking a specific question.
|
|
_PROGRESS_NARRATION_PATTERNS = [
|
|
re.compile(r"\b(next|then)\s+i\s+will\b", re.IGNORECASE),
|
|
re.compile(r"\bi\s+did\s+not\s+attempt\b", re.IGNORECASE),
|
|
re.compile(r"\bunless\s+you\s+want\b", re.IGNORECASE),
|
|
re.compile(r"\bi\s+will\s+(?:now\s+)?proceed\b", re.IGNORECASE),
|
|
re.compile(r"\bi\s+have\s+not\s+yet\b", re.IGNORECASE),
|
|
]
|
|
|
|
PRESENT_COMPLETION_CONTRACT_ASK_RETRY = (
|
|
"The final ASK_QUESTION is not an allowed terminal response for this turn: the request already has a typed "
|
|
"completion contract / completion criteria and no separate required clarification is active. Continue authoring "
|
|
"the workflow from the existing contract, then run/test it before responding. Only ask the user if a separate "
|
|
"required input is missing under RequestPolicy or TurnIntent."
|
|
)
|
|
|
|
|
|
def _is_progress_narration(user_response: Any) -> bool:
|
|
if not isinstance(user_response, str) or not user_response:
|
|
return False
|
|
return any(pattern.search(user_response) for pattern in _PROGRESS_NARRATION_PATTERNS)
|
|
|
|
|
|
def _normalized_proxy_label(proxy_location: Any) -> str | None:
|
|
if proxy_location is None:
|
|
return None
|
|
raw_value = getattr(proxy_location, "value", proxy_location)
|
|
if isinstance(raw_value, dict):
|
|
country = raw_value.get("country")
|
|
subdivision = raw_value.get("subdivision")
|
|
city = raw_value.get("city")
|
|
parts = [str(part).strip() for part in (country, subdivision, city) if part]
|
|
return "-".join(parts) if parts else None
|
|
value = str(raw_value).strip()
|
|
if not value or value.upper() in {"NONE", "NULL", "NO_PROXY"}:
|
|
return None
|
|
return value
|
|
|
|
|
|
def _effective_proxy_label(ctx: Any) -> str | None:
|
|
effective_raw = getattr(ctx, "effective_workflow_proxy_location", None)
|
|
if effective_raw is not None:
|
|
return _normalized_proxy_label(effective_raw)
|
|
workflow = getattr(ctx, "last_workflow", None)
|
|
if workflow is None:
|
|
return None
|
|
return _normalized_proxy_label(getattr(workflow, "proxy_location", None))
|
|
|
|
|
|
def _probable_site_block_proxy_options(ctx: Any, *, include_whether: bool = True) -> str:
|
|
proxy_label = _effective_proxy_label(ctx)
|
|
if proxy_label is None:
|
|
options = "try a different URL, configure a proxy, or provide an alternate entry point."
|
|
return f"whether to {options}" if include_whether else options
|
|
if proxy_label == "RESIDENTIAL":
|
|
options = (
|
|
"try a different proxy location (for example US-CA or US-NY), use a different "
|
|
"residential/ISP option if supported, or provide an alternate entry point."
|
|
)
|
|
return f"whether to {options}" if include_whether else options
|
|
options = (
|
|
f"try a different proxy/location than {proxy_label}, use a different residential/ISP option if supported, "
|
|
"or provide an alternate entry point."
|
|
)
|
|
return f"whether to {options}" if include_whether else options
|
|
|
|
|
|
def _probable_site_block_stop_nudge(ctx: Any, config: CopilotConfig | None = None) -> str:
|
|
return _nudge(config, "post_probable_site_block_stop_prefix") + _probable_site_block_proxy_options(ctx)
|
|
|
|
|
|
def _probable_site_block_stop_agent_text(ctx: Any, config: CopilotConfig | None = None) -> str:
|
|
return (
|
|
f"{_probable_site_block_stop_nudge(ctx, config)}\n"
|
|
f"Latest internal failure reason: {_single_line_failure_reason(ctx)}"
|
|
)
|
|
|
|
|
|
def _single_line_failure_reason(ctx: Any) -> str:
|
|
reason = getattr(ctx, "last_test_failure_reason", None)
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
return "Skyvern failed to load the website."
|
|
return " ".join(reason.split())
|
|
|
|
|
|
def build_probable_site_block_user_question(ctx: Any) -> str | None:
|
|
"""Return a concise user-facing blocker question after the site-block stop nudge."""
|
|
if _get_int(ctx, "probable_site_block_stop_nudge_count") <= 0:
|
|
return None
|
|
|
|
failure_reason = _single_line_failure_reason(ctx)
|
|
options = _probable_site_block_proxy_options(ctx, include_whether=False)
|
|
return (
|
|
"The site could not be loaded after repeated attempts. "
|
|
f'The latest failure_reason was: "{failure_reason}". '
|
|
"Repeating the same IP/workflow shape is unlikely to help, so I should stop this path.\n\n"
|
|
f"Would you like me to {options}"
|
|
)
|
|
|
|
|
|
def _probable_site_block_stop_signal(ctx: Any, config: CopilotConfig | None = None) -> CopilotToolBlockerSignal:
|
|
user_facing = build_probable_site_block_user_question(ctx)
|
|
if user_facing is None:
|
|
user_facing = (
|
|
"The site could not be loaded after repeated attempts. Tell me whether to try a different URL, "
|
|
"configure a proxy, or use an alternate entry point."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text=_probable_site_block_stop_agent_text(ctx, config),
|
|
user_facing_reason=user_facing,
|
|
recovery_hint="ask_user_clarifying",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=True,
|
|
internal_reason_code="probable_site_block_stop",
|
|
blocked_tool="update_and_run_blocks",
|
|
)
|
|
|
|
|
|
def _repair_loop_state(ctx: Any) -> RepairLoopState | None:
|
|
contract = getattr(ctx, "latest_diagnosis_repair_contract", None)
|
|
state = getattr(contract, "repair_loop_state", None)
|
|
return state if isinstance(state, RepairLoopState) else None
|
|
|
|
|
|
def _needs_repair_ceiling_halt(ctx: Any) -> bool:
|
|
state = _repair_loop_state(ctx)
|
|
return state is not None and state.ceiling_reached is True and run_backed_repair_evidence_exists(ctx)
|
|
|
|
|
|
def repair_ceiling_stop_signal(
|
|
ctx: Any,
|
|
contract: DiagnosisRepairContract | None,
|
|
config: CopilotConfig | None = None,
|
|
) -> CopilotToolBlockerSignal:
|
|
state = contract.repair_loop_state if isinstance(contract, DiagnosisRepairContract) else None
|
|
count = state.consecutive_identical_repair_count if state is not None else 0
|
|
evidence = loop_blocker_evidence_from_ctx(ctx)
|
|
user_facing, _tiers = compose_loop_blocker_user_facing_reason(
|
|
"repair_ceiling_reached", evidence, blocked_tool="update_and_run_blocks"
|
|
)
|
|
agent_steering = (
|
|
f"This repair has made no verified progress across {count} attempts; "
|
|
"stop retrying and report the recorded blocker from the preserved draft."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text=agent_steering,
|
|
user_facing_reason=user_facing,
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=evidence.has_draft,
|
|
renders_final_reply=True,
|
|
internal_reason_code="repair_ceiling_reached",
|
|
blocked_tool="update_and_run_blocks",
|
|
)
|
|
|
|
|
|
def code_authoring_churn_stop_signal(ctx: Any) -> CopilotToolBlockerSignal:
|
|
count = _get_int(ctx, "code_authoring_guardrail_reject_count")
|
|
evidence = loop_blocker_evidence_from_ctx(ctx)
|
|
user_facing, _tiers = compose_loop_blocker_user_facing_reason(
|
|
"code_authoring_guardrail_churn", evidence, blocked_tool="update_workflow"
|
|
)
|
|
agent_steering = (
|
|
f"The generated code has been rejected {count} times without an accepted save; "
|
|
"stop rewriting it and report the recorded blocker from the preserved draft."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text=agent_steering,
|
|
user_facing_reason=user_facing,
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=evidence.has_draft,
|
|
renders_final_reply=True,
|
|
internal_reason_code="code_authoring_guardrail_churn",
|
|
blocked_tool="update_workflow",
|
|
)
|
|
|
|
|
|
def credential_priority_authoring_churn_stop_signal(ctx: Any) -> CopilotToolBlockerSignal:
|
|
count = _get_int(ctx, "code_authoring_guardrail_reject_count")
|
|
evidence = loop_blocker_evidence_from_ctx(ctx)
|
|
user_facing, _tiers = compose_loop_blocker_user_facing_reason(
|
|
"credential_priority_authoring_churn", evidence, blocked_tool="update_workflow"
|
|
)
|
|
agent_steering = (
|
|
f"The credential-scout gate has rejected the generated code {count} times without an accepted save; "
|
|
"stop rewriting it and report the recorded blocker from the preserved draft."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text=agent_steering,
|
|
user_facing_reason=user_facing,
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=evidence.has_draft,
|
|
renders_final_reply=True,
|
|
internal_reason_code="credential_priority_authoring_churn",
|
|
blocked_tool="update_workflow",
|
|
)
|
|
|
|
|
|
def no_forward_progress_interaction_stop_signal(ctx: Any) -> CopilotToolBlockerSignal:
|
|
count = _get_int(ctx, "consecutive_no_progress_interaction_count")
|
|
evidence = loop_blocker_evidence_from_ctx(ctx)
|
|
user_facing, _tiers = compose_loop_blocker_user_facing_reason(
|
|
"loop_detected_no_forward_progress_interaction", evidence, blocked_tool="click"
|
|
)
|
|
agent_steering = (
|
|
f"Clicking has not advanced the page across {count} attempts; "
|
|
"stop trying new selectors and report the recorded blocker from the preserved draft."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="loop_detected",
|
|
agent_steering_text=agent_steering,
|
|
user_facing_reason=user_facing,
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=evidence.has_draft,
|
|
renders_final_reply=True,
|
|
internal_reason_code="loop_detected_no_forward_progress_interaction",
|
|
blocked_tool="click",
|
|
)
|
|
|
|
|
|
def _needs_no_progress_interaction_halt(ctx: Any) -> bool:
|
|
return _get_int(ctx, "consecutive_no_progress_interaction_count") >= MAX_NO_PROGRESS_INTERACTION_ATTEMPTS
|
|
|
|
|
|
def reset_no_progress_interaction_count(ctx: Any) -> None:
|
|
if _get_int(ctx, "consecutive_no_progress_interaction_count") == 0:
|
|
return
|
|
ctx.consecutive_no_progress_interaction_count = 0
|
|
clear_tool_blocker_signals_for_reason_codes(ctx, _NO_PROGRESS_INTERACTION_REASON_CODES)
|
|
LOG.info("copilot_no_progress_interaction_reset")
|
|
|
|
|
|
def register_no_progress_interaction_click(ctx: Any, *, outcome: str) -> None:
|
|
count = _get_int(ctx, "consecutive_no_progress_interaction_count") + 1
|
|
ctx.consecutive_no_progress_interaction_count = count
|
|
LOG.info("copilot_no_progress_interaction_click", outcome=outcome, count=count)
|
|
if count < MAX_NO_PROGRESS_INTERACTION_ATTEMPTS:
|
|
return
|
|
if blocker_signal_is_genuinely_terminal(ctx.blocker_signal):
|
|
return
|
|
signal = no_forward_progress_interaction_stop_signal(ctx)
|
|
stash_blocker_signal(ctx, signal)
|
|
ctx.blocker_signal = signal
|
|
|
|
|
|
def _needs_code_authoring_churn_halt(ctx: Any) -> bool:
|
|
count = _get_int(ctx, "code_authoring_guardrail_reject_count")
|
|
if getattr(ctx, "last_code_authoring_reject_was_credential_priority", False) is True:
|
|
return count >= MAX_CREDENTIAL_PRIORITY_AUTHORING_REJECTS
|
|
return count >= MAX_CODE_AUTHORING_GUARDRAIL_REJECTS
|
|
|
|
|
|
def _churn_signal_if_halting(ctx: Any) -> CopilotToolBlockerSignal | None:
|
|
if not _needs_code_authoring_churn_halt(ctx):
|
|
return None
|
|
if getattr(ctx, "last_code_authoring_reject_was_credential_priority", False) is True:
|
|
return credential_priority_authoring_churn_stop_signal(ctx)
|
|
return code_authoring_churn_stop_signal(ctx)
|
|
|
|
|
|
def _typed_terminal_challenge_outcome(ctx: Any) -> RecordedRunOutcome | None:
|
|
outcome = getattr(ctx, "last_run_outcome", None)
|
|
if not isinstance(outcome, RecordedRunOutcome):
|
|
return None
|
|
if outcome.reason_code != TERMINAL_CHALLENGE_RUN_OUTCOME_REASON_CODE:
|
|
return None
|
|
return outcome
|
|
|
|
|
|
def _structured_page_challenge_reason(ctx: Any, evidence: dict[str, Any] | None = None) -> str | None:
|
|
if evidence is None:
|
|
evidence = getattr(ctx, "composition_page_evidence", None)
|
|
if not isinstance(evidence, dict):
|
|
return None
|
|
challenge_state = evidence.get("challenge_state")
|
|
if isinstance(challenge_state, dict) and challenge_state.get("detected") is True:
|
|
# This raw page kind is folded into an internal reason here; halt
|
|
# metadata sanitizes it through run_outcome_display_reason below.
|
|
kind = str(challenge_state.get("kind") or "site challenge").replace("_", " ")
|
|
if challenge_state.get("requires_human_verification") is True:
|
|
if "verification" in kind.lower() or "captcha" in kind.lower():
|
|
return f"{kind} requires manual completion"
|
|
return f"{kind} requires human verification"
|
|
if challenge_state.get("gates_submit_controls") is True:
|
|
return f"{kind} gates the submit/search controls"
|
|
controls = evidence.get("challenge_controls")
|
|
if isinstance(controls, list) and interactive_challenge_controls(controls):
|
|
return "interactive challenge controls are visible on the page"
|
|
return None
|
|
|
|
|
|
def _terminal_challenge_halt_signal(
|
|
ctx: Any,
|
|
*,
|
|
evidence_source: str,
|
|
evidence_reason: str,
|
|
blocked_tool: str = "update_and_run_blocks",
|
|
) -> CopilotToolBlockerSignal:
|
|
workflow_run_id = getattr(ctx, "last_run_blocks_workflow_run_id", None)
|
|
safe_evidence_reason = (
|
|
run_outcome_display_reason(evidence_reason) or "Structured challenge evidence reported a terminal blocker."
|
|
)
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text=(
|
|
"Structured challenge evidence confirms this path is blocked: "
|
|
f"{safe_evidence_reason}. Do NOT retry block-running tools, do NOT try a proxy/location switch "
|
|
"in this turn, and do NOT claim the workflow is verified end-to-end. Reply with the blocker."
|
|
),
|
|
user_facing_reason=TERMINAL_CHALLENGE_USER_FACING_REASON,
|
|
recovery_hint="report_blocker_to_user",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=True,
|
|
internal_reason_code=TERMINAL_CHALLENGE_BLOCKER_REASON_CODE,
|
|
blocked_tool=blocked_tool,
|
|
extra={
|
|
"run_outcome_reason_code": TERMINAL_CHALLENGE_RUN_OUTCOME_REASON_CODE,
|
|
"evidence_source": evidence_source,
|
|
"evidence_reason": safe_evidence_reason,
|
|
"workflow_run_id": workflow_run_id if isinstance(workflow_run_id, str) else None,
|
|
},
|
|
)
|
|
|
|
|
|
def terminal_challenge_blocker_signal_from_page_evidence(
|
|
ctx: Any,
|
|
*,
|
|
blocked_tool: str,
|
|
evidence_source: str = "page_evidence",
|
|
evidence: dict[str, Any] | None = None,
|
|
) -> CopilotToolBlockerSignal | None:
|
|
page_reason = _structured_page_challenge_reason(ctx, evidence)
|
|
if page_reason is None:
|
|
return None
|
|
return _terminal_challenge_halt_signal(
|
|
ctx,
|
|
evidence_source=evidence_source,
|
|
evidence_reason=page_reason,
|
|
blocked_tool=blocked_tool,
|
|
)
|
|
|
|
|
|
def _current_page_challenge_requires_stop(evidence: dict[str, Any]) -> bool:
|
|
challenge_state = evidence.get("challenge_state")
|
|
if isinstance(challenge_state, dict) and (
|
|
challenge_state.get("requires_human_verification") is True
|
|
or challenge_state.get("gates_submit_controls") is True
|
|
):
|
|
return True
|
|
controls = evidence.get("challenge_controls")
|
|
return isinstance(controls, list) and bool(interactive_challenge_controls(controls))
|
|
|
|
|
|
def _current_page_evidence_candidates(ctx: Any) -> list[dict[str, Any]]:
|
|
candidates: list[dict[str, Any]] = []
|
|
for entry in reversed(getattr(ctx, "flow_evidence", None) or []):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
packet = entry.get("evidence")
|
|
if isinstance(packet, dict):
|
|
candidates.append(packet)
|
|
single = getattr(ctx, "composition_page_evidence", None)
|
|
if isinstance(single, dict):
|
|
candidates.append(single)
|
|
return candidates
|
|
|
|
|
|
def terminal_challenge_blocker_signal_from_current_page_evidence(
|
|
ctx: Any,
|
|
*,
|
|
blocked_tool: str,
|
|
evidence_source: str = "page_evidence",
|
|
) -> CopilotToolBlockerSignal | None:
|
|
if getattr(ctx, "last_failure_category_top", None) == PER_TOOL_BUDGET_FAILURE_CATEGORY:
|
|
return None
|
|
for evidence in _current_page_evidence_candidates(ctx):
|
|
if evidence.get("observed_after_workflow_run") is not True:
|
|
continue
|
|
if not _current_page_challenge_requires_stop(evidence):
|
|
continue
|
|
signal = terminal_challenge_blocker_signal_from_page_evidence(
|
|
ctx,
|
|
blocked_tool=blocked_tool,
|
|
evidence_source=evidence_source,
|
|
evidence=evidence,
|
|
)
|
|
if signal is not None:
|
|
return signal
|
|
return None
|
|
|
|
|
|
def _maybe_stash_terminal_challenge_halt(ctx: Any) -> None:
|
|
if getattr(ctx, "turn_halt", None) is not None:
|
|
return
|
|
outcome = _typed_terminal_challenge_outcome(ctx)
|
|
if outcome is not None:
|
|
reason = outcome.display_reason or "Structured evidence reported a terminal site challenge."
|
|
signal = _terminal_challenge_halt_signal(ctx, evidence_source="run_outcome", evidence_reason=reason)
|
|
stash_blocker_signal(ctx, signal)
|
|
stash_turn_halt_from_blocker_signal(ctx, signal, source="enforcement")
|
|
return
|
|
# `last_test_ok is False` is the failed-run sentinel for this backstop.
|
|
# Free-standing visible challenge hints remain diagnostic until a run/test
|
|
# also records anti-bot evidence.
|
|
if getattr(ctx, "last_test_ok", None) is not False:
|
|
return
|
|
if not getattr(ctx, "last_test_anti_bot", None):
|
|
return
|
|
page_signal = terminal_challenge_blocker_signal_from_page_evidence(ctx, blocked_tool="update_and_run_blocks")
|
|
if page_signal is None:
|
|
return
|
|
stash_blocker_signal(ctx, page_signal)
|
|
stash_turn_halt_from_blocker_signal(ctx, page_signal, source="enforcement")
|
|
|
|
|
|
class CopilotTotalTimeoutError(Exception):
|
|
"""Raised when the copilot agent exceeds the total allowed runtime."""
|
|
|
|
|
|
class CopilotGoalSatisfied(Exception):
|
|
"""Raised when a tool proves the workflow already satisfies the turn."""
|
|
|
|
|
|
class CopilotBuiltUnverified(Exception):
|
|
"""Raised when clean tests should stop repair without claiming goal success."""
|
|
|
|
|
|
BUILT_UNVERIFIED_REPAIR_INERT_TERMINAL_REASON = "built_unverified_repair_inert"
|
|
|
|
|
|
def latest_diagnosis_contract_satisfies_goal(ctx: CopilotContext) -> bool:
|
|
contract = ctx.latest_diagnosis_repair_contract
|
|
if contract is None:
|
|
return False
|
|
verification = contract.verification_result
|
|
repair_decision = contract.repair_decision
|
|
return (
|
|
verification.user_goal_satisfied is True
|
|
and verification.completion_contract_satisfied is True
|
|
and repair_decision.next_action is RepairNextAction.NO_CHANGE
|
|
)
|
|
|
|
|
|
def _latest_diagnosis_contract_selects_no_repair(ctx: CopilotContext) -> bool:
|
|
contract = ctx.latest_diagnosis_repair_contract
|
|
return contract is not None and contract.repair_decision.next_action is RepairNextAction.NO_CHANGE
|
|
|
|
|
|
def _outcome_criteria_evaluated(ctx: CopilotContext) -> bool:
|
|
return outcome_criteria_evaluated(ctx)
|
|
|
|
|
|
def _completion_verification_only_structural_abstentions(ctx: CopilotContext) -> bool:
|
|
result = ctx.completion_verification_result
|
|
return result is not None and only_structural_requested_output_abstentions(result)
|
|
|
|
|
|
def verified_goal_satisfied_context(ctx: CopilotContext) -> bool:
|
|
if outcome_fully_verified(ctx):
|
|
return True
|
|
# The judge verdict is authoritative: once it has evaluated, an unconfirmed
|
|
# criterion means the outcome is unmet regardless of run status. The block-count
|
|
# heuristic (which counts method verbs in the request) governs only when there
|
|
# is no evaluated verdict.
|
|
if _outcome_criteria_evaluated(ctx):
|
|
return False
|
|
if not (
|
|
ctx.last_test_ok is True
|
|
and ctx.last_full_workflow_test_ok is True
|
|
and latest_diagnosis_contract_satisfies_goal(ctx)
|
|
):
|
|
return False
|
|
return not _verified_goal_likely_needs_more_work(ctx)
|
|
|
|
|
|
def built_unverified_repair_inert_context(ctx: CopilotContext) -> bool:
|
|
return (
|
|
ctx.last_test_ok is True
|
|
and ctx.last_full_workflow_test_ok is True
|
|
and _outcome_criteria_evaluated(ctx)
|
|
and _latest_diagnosis_contract_selects_no_repair(ctx)
|
|
and _completion_verification_only_structural_abstentions(ctx)
|
|
and not _verified_goal_likely_needs_more_work(ctx)
|
|
)
|
|
|
|
|
|
def verified_goal_claim_authorized(ctx: CopilotContext) -> bool:
|
|
"""Whether the terminal may CLAIM a tested success. Turn completion keeps
|
|
flowing through ``verified_goal_satisfied_context``; the claim tier additionally
|
|
requires judge-confirmed outcome evidence — criteria-less or judge-less terminals
|
|
end the turn but render built-but-unverified."""
|
|
return outcome_fully_verified(ctx)
|
|
|
|
|
|
def gate_decision_trace_fields(ctx: CopilotContext) -> dict[str, bool]:
|
|
"""The terminal-gate decision plus the conjuncts that explain it.
|
|
|
|
Captured wherever the gate is evaluated (including when it returns False, the
|
|
signal that explains why the turn continued) so a single trace shows whether
|
|
the gate failed on the test, the full-workflow run, the diagnosis contract,
|
|
the absence of outcome verification, or the block-count heuristic.
|
|
"""
|
|
return {
|
|
"gate_satisfied": verified_goal_satisfied_context(ctx),
|
|
"gate_built_unverified_repair_inert": built_unverified_repair_inert_context(ctx),
|
|
"gate_claim_authorized": verified_goal_claim_authorized(ctx),
|
|
"gate_last_test_ok": ctx.last_test_ok is True,
|
|
"gate_last_full_workflow_test_ok": ctx.last_full_workflow_test_ok is True,
|
|
"gate_diagnosis_contract_satisfies_goal": latest_diagnosis_contract_satisfies_goal(ctx),
|
|
"gate_outcome_criteria_evaluated": _outcome_criteria_evaluated(ctx),
|
|
"gate_artifact_health_blocked": artifact_health_blocked(ctx),
|
|
"gate_likely_needs_more_work": _verified_goal_likely_needs_more_work(ctx),
|
|
"gate_evaluated_this_turn": True,
|
|
}
|
|
|
|
|
|
def _verified_goal_likely_needs_more_work(ctx: CopilotContext) -> bool:
|
|
block_count = ctx.last_update_block_count
|
|
if not isinstance(block_count, int):
|
|
return False
|
|
user_message = ctx.user_message
|
|
completion_contract = _request_completion_contract(ctx)
|
|
return _goal_likely_needs_more_blocks(user_message, block_count, completion_contract)
|
|
|
|
|
|
def _mark_copilot_total_timeout(ctx: Any) -> None:
|
|
ctx.copilot_total_timeout_exceeded = True
|
|
|
|
|
|
def _mark_copilot_total_timeout_if_elapsed(ctx: Any, start_time: float) -> None:
|
|
if time.monotonic() - start_time >= TOTAL_TIMEOUT_SECONDS:
|
|
_mark_copilot_total_timeout(ctx)
|
|
|
|
|
|
class CopilotNonRetriableNavError(Exception):
|
|
"""Raised from run_with_enforcement when the copilot's most recent run
|
|
hit a permanent navigation error (DNS / cert / SSL / invalid URL) and
|
|
the loop is about to exit without a successful test. Caught at the
|
|
agent entrypoint and translated to a deterministic user-facing failure,
|
|
mirroring the CopilotTotalTimeoutError handling pattern."""
|
|
|
|
def __init__(self, url: str | None, error_message: str) -> None:
|
|
self.url = url
|
|
self.error_message = error_message
|
|
super().__init__(f"Non-retriable navigation error: {error_message}")
|
|
|
|
|
|
_FAILED_TO_NAVIGATE_URL_PATTERN = re.compile(r"Failed to navigate to url (\S+)\. Error message:")
|
|
|
|
|
|
def _extract_url_from_nav_error(message: str) -> str | None:
|
|
"""Pull the URL out of a FailedToNavigateToUrl string. None on no match."""
|
|
match = _FAILED_TO_NAVIGATE_URL_PATTERN.search(message)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def _maybe_raise_non_retriable_nav(ctx: Any) -> None:
|
|
"""Raise CopilotNonRetriableNavError if the most recent run was a
|
|
permanent navigation failure and nothing else has succeeded. Called
|
|
before both `return result` sites in run_with_enforcement so the loop
|
|
cannot hand a failed run back to the caller as if it completed."""
|
|
err = getattr(ctx, "last_test_non_retriable_nav_error", None)
|
|
if not isinstance(err, str) or not err:
|
|
return
|
|
if getattr(ctx, "last_test_ok", None) is True:
|
|
return
|
|
raise CopilotNonRetriableNavError(url=_extract_url_from_nav_error(err), error_message=err)
|
|
|
|
|
|
class CopilotUnrecoverableToolError(Exception):
|
|
"""Raised when browser-session tool failures prove the current loop cannot recover."""
|
|
|
|
def __init__(self, tool_name: str, error_message: str) -> None:
|
|
self.tool_name = tool_name
|
|
self.error_message = error_message
|
|
super().__init__(f"Unrecoverable tool error in {tool_name}: {error_message}")
|
|
|
|
|
|
_BROWSER_SESSION_TOOL_NAMES = frozenset(
|
|
{
|
|
"navigate_browser",
|
|
"get_browser_screenshot",
|
|
"evaluate",
|
|
"click",
|
|
"type_text",
|
|
"scroll",
|
|
"console_messages",
|
|
"select_option",
|
|
"press_key",
|
|
}
|
|
)
|
|
_POST_RUN_PAGE_OBSERVATION_TOOLS = frozenset({"evaluate", "get_browser_screenshot", "inspect_page_for_composition"})
|
|
_UNRECOVERABLE_TOOL_ERROR_CATEGORY = "UNRECOVERABLE_TOOL_ERROR"
|
|
_BROWSER_SESSION_ID_RE = re.compile(r"\bpbs_[A-Za-z0-9_-]+\b")
|
|
_BROWSER_SESSION_WITH_ID_RE = re.compile(r"\bbrowser session\s+pbs_[A-Za-z0-9_-]+\b", re.IGNORECASE)
|
|
|
|
|
|
def redact_browser_session_references(value: str) -> str:
|
|
value = _BROWSER_SESSION_WITH_ID_RE.sub("Browser session", value)
|
|
return _BROWSER_SESSION_ID_RE.sub("the browser session", value)
|
|
|
|
|
|
def _result_text_values(value: Any) -> list[str]:
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, dict):
|
|
result: list[str] = []
|
|
for item in value.values():
|
|
result.extend(_result_text_values(item))
|
|
return result
|
|
if isinstance(value, list):
|
|
result = []
|
|
for item in value:
|
|
result.extend(_result_text_values(item))
|
|
return result
|
|
return []
|
|
|
|
|
|
def _unrecoverable_tool_error_reason(output: dict[str, Any]) -> str:
|
|
raw_reason = output.get("error")
|
|
if not isinstance(raw_reason, str) or not raw_reason.strip():
|
|
data = output.get("data")
|
|
raw_reason = data.get("failure_reason") if isinstance(data, dict) else None
|
|
if not isinstance(raw_reason, str) or not raw_reason.strip():
|
|
raw_reason = " ".join(_result_text_values(output))
|
|
reason = " ".join(str(raw_reason or "Browser session was no longer reachable.").split())
|
|
reason = redact_browser_session_references(reason)
|
|
return reason[:240].rstrip()
|
|
|
|
|
|
def _is_unrecoverable_browser_session_error(tool_name: str, output: dict[str, Any]) -> bool:
|
|
if tool_name not in _BROWSER_SESSION_TOOL_NAMES or output.get("ok", True):
|
|
return False
|
|
lowered = " ".join(_result_text_values(output)).lower()
|
|
if "no browser context" in lowered:
|
|
return True
|
|
has_session_signal = "browser session" in lowered or "browser context" in lowered
|
|
has_lost_signal = "not found" in lowered or "404" in lowered
|
|
return has_session_signal and has_lost_signal
|
|
|
|
|
|
def _record_unrecoverable_tool_error_contract(ctx: Any, tool_name: str, reason: str) -> None:
|
|
from skyvern.forge.sdk.copilot.diagnosis_repair_contract import build_diagnosis_repair_contract
|
|
|
|
result = {
|
|
"ok": False,
|
|
"error": reason,
|
|
"data": {
|
|
"overall_status": "aborted",
|
|
"failure_reason": reason,
|
|
"failure_categories": [{"category": _UNRECOVERABLE_TOOL_ERROR_CATEGORY, "reasoning": reason}],
|
|
},
|
|
}
|
|
contract = build_diagnosis_repair_contract(source_tool=tool_name, result=result, ctx=ctx)
|
|
ctx.latest_diagnosis_repair_contract = contract
|
|
ctx.unrecoverable_tool_error_reason = reason
|
|
ctx.unrecoverable_tool_error_tool_name = tool_name
|
|
ctx.last_test_failure_reason = reason
|
|
trace_data = contract.to_trace_data()
|
|
LOG.warning(
|
|
"Copilot unrecoverable tool error stop",
|
|
tool_name=tool_name,
|
|
error_reason=reason,
|
|
**{f"diagnosis_repair_{key}": value for key, value in trace_data.items()},
|
|
)
|
|
with copilot_span("copilot_unrecoverable_tool_error", data={"tool_name": tool_name, **trace_data}):
|
|
pass
|
|
|
|
|
|
def _maybe_raise_unrecoverable_tool_error(ctx: Any, tool_name: str, output: dict[str, Any]) -> None:
|
|
if not _is_unrecoverable_browser_session_error(tool_name, output):
|
|
if tool_name in _BROWSER_SESSION_TOOL_NAMES and output.get("ok", False):
|
|
ctx.unrecoverable_tool_error_streak_count = 0
|
|
ctx.unrecoverable_tool_error_signature = None
|
|
return
|
|
|
|
reason = _unrecoverable_tool_error_reason(output)
|
|
signature = "browser_session_unreachable"
|
|
prior_signature = getattr(ctx, "unrecoverable_tool_error_signature", None)
|
|
prior_count = getattr(ctx, "unrecoverable_tool_error_streak_count", 0)
|
|
prior_count = prior_count if isinstance(prior_count, int) else 0
|
|
count = prior_count + 1 if prior_signature == signature else 1
|
|
ctx.unrecoverable_tool_error_signature = signature
|
|
ctx.unrecoverable_tool_error_streak_count = count
|
|
ctx.unrecoverable_tool_error_reason = reason
|
|
ctx.unrecoverable_tool_error_tool_name = tool_name
|
|
|
|
if count >= UNRECOVERABLE_TOOL_ERROR_STOP_AT:
|
|
_record_unrecoverable_tool_error_contract(ctx, tool_name, reason)
|
|
raise CopilotUnrecoverableToolError(tool_name, reason)
|
|
|
|
|
|
def _raise_if_unrecoverable_contract_stop(ctx: Any) -> None:
|
|
contract = getattr(ctx, "latest_diagnosis_repair_contract", None)
|
|
diagnosis = getattr(contract, "diagnosis_result", None)
|
|
repair_decision = getattr(contract, "repair_decision", None)
|
|
failure_type = getattr(getattr(diagnosis, "suspected_failure_type", None), "value", None) or getattr(
|
|
diagnosis,
|
|
"suspected_failure_type",
|
|
None,
|
|
)
|
|
next_action = getattr(getattr(repair_decision, "next_action", None), "value", None) or getattr(
|
|
repair_decision,
|
|
"next_action",
|
|
None,
|
|
)
|
|
if failure_type != "unrecoverable_tool_error" or next_action != "stop":
|
|
return
|
|
verification = getattr(contract, "verification_result", None)
|
|
reason = getattr(verification, "remaining_blocker", None) or getattr(diagnosis, "root_cause_summary", None)
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
reason = "Browser session was no longer reachable."
|
|
source_tool = getattr(getattr(contract, "diagnosis_input", None), "source_tool", None)
|
|
tool_name = source_tool if isinstance(source_tool, str) and source_tool else "unknown"
|
|
raise CopilotUnrecoverableToolError(tool_name, reason)
|
|
|
|
|
|
_ACTION_CATEGORIES: list[list[str]] = [
|
|
["navigate", "go to", "open", "visit"],
|
|
["download", "save", "export"],
|
|
["extract", "scrape", "collect", "gather", "get all", "grab", "capture", "retrieve", "pull"],
|
|
["login", "log in", "sign in", "authenticate"],
|
|
["search", "find", "look for", "look up", "check", "verify"],
|
|
["fill", "enter", "type", "submit", "complete the form", "input"],
|
|
["click", "select", "choose", "pick"],
|
|
["upload", "attach"],
|
|
]
|
|
|
|
_SEQUENTIAL_CONNECTORS = [" and then ", " then ", " after that ", " next ", " followed by ", " afterward "]
|
|
|
|
|
|
def _request_completion_contract(ctx: Any) -> str | None:
|
|
request_policy = getattr(ctx, "request_policy", None)
|
|
completion_contract = getattr(request_policy, "completion_contract", None)
|
|
if isinstance(completion_contract, str) and completion_contract.strip():
|
|
return completion_contract.strip()
|
|
return None
|
|
|
|
|
|
def _request_completion_contract_status(ctx: Any) -> str:
|
|
request_policy = getattr(ctx, "request_policy", None)
|
|
status = getattr(request_policy, "completion_contract_status", None)
|
|
if status in ("present", "absent", "unknown"):
|
|
return status
|
|
return "present" if _request_completion_contract(ctx) else "absent"
|
|
|
|
|
|
def _completion_contract_unknown_due_to_policy_fallback(ctx: Any) -> bool:
|
|
return _request_completion_contract_status(ctx) == "unknown"
|
|
|
|
|
|
_AUTHORING_TURN_INTENT_MODES = frozenset({TurnIntentMode.BUILD, TurnIntentMode.EDIT, TurnIntentMode.DRAFT_ONLY})
|
|
|
|
|
|
def _turn_intent_can_author_without_user_input(turn_intent: Any) -> bool:
|
|
if not isinstance(turn_intent, TurnIntent):
|
|
return False
|
|
if turn_intent.mode not in _AUTHORING_TURN_INTENT_MODES:
|
|
return False
|
|
if turn_intent.authority.requires_user_input:
|
|
return False
|
|
return turn_intent.authority.may_update_workflow
|
|
|
|
|
|
def _turn_intent_can_update_and_run_without_user_input(turn_intent: Any) -> bool:
|
|
if not _turn_intent_can_author_without_user_input(turn_intent):
|
|
return False
|
|
return bool(turn_intent.authority.may_run_blocks)
|
|
|
|
|
|
def recycle_admits_present_completion_contract_ask(ctx: CopilotContext) -> bool:
|
|
request_policy = ctx.request_policy
|
|
if not isinstance(request_policy, RequestPolicy):
|
|
return False
|
|
if not request_policy_has_present_completion_contract(request_policy):
|
|
return False
|
|
if request_policy.user_response_policy == "ask_clarification":
|
|
return False
|
|
if request_policy.clarification_reason not in (None, "none"):
|
|
return False
|
|
if not _turn_intent_can_author_without_user_input(ctx.turn_intent):
|
|
return False
|
|
if ctx.has_genuine_workflow_attempt():
|
|
return False
|
|
return True
|
|
|
|
|
|
def _present_completion_contract_ask_retry(ctx: CopilotContext, parsed: dict[str, Any]) -> str | None:
|
|
if parsed.get("type") != "ASK_QUESTION":
|
|
return None
|
|
if not recycle_admits_present_completion_contract_ask(ctx):
|
|
return None
|
|
LOG.info(
|
|
"copilot.present_completion_contract_ask_retry",
|
|
reason_code="present_completion_contract_ask_internal_retry",
|
|
turn_intent_mode=ctx.turn_intent.mode if ctx.turn_intent else None,
|
|
**ctx.genuine_attempt_parity_fields(),
|
|
)
|
|
return PRESENT_COMPLETION_CONTRACT_ASK_RETRY
|
|
|
|
|
|
def _nudge(config: CopilotConfig | None, key: str) -> str:
|
|
if config is None:
|
|
return DEFAULT_ENFORCEMENT_NUDGES[key]
|
|
return config.nudge(key)
|
|
|
|
|
|
def _goal_likely_needs_more_blocks(user_message: Any, block_count: int, completion_contract: str | None = None) -> bool:
|
|
"""Return True when the goal likely requires more blocks than currently exist."""
|
|
if block_count >= MIN_BLOCKS_FOR_AUTO_COMPLETE:
|
|
return False
|
|
if not isinstance(user_message, str):
|
|
return False
|
|
text = user_message.lower()
|
|
has_sequential = any(conn in text for conn in _SEQUENTIAL_CONNECTORS)
|
|
if block_count >= 1 and completion_contract:
|
|
return has_sequential and block_count < 2
|
|
|
|
matched_categories = sum(1 for category in _ACTION_CATEGORIES if any(keyword in text for keyword in category))
|
|
|
|
estimated_min_blocks = max(matched_categories, 2) if has_sequential else matched_categories
|
|
return block_count < estimated_min_blocks
|
|
|
|
|
|
def _same_page(left: str | None, right: str | None) -> bool:
|
|
if not left or not right:
|
|
return False
|
|
try:
|
|
left_parsed = urlparse(left)
|
|
right_parsed = urlparse(right)
|
|
except Exception:
|
|
return False
|
|
if not left_parsed.netloc or not right_parsed.netloc:
|
|
return False
|
|
if left_parsed.netloc.lower() != right_parsed.netloc.lower():
|
|
return False
|
|
left_path = (left_parsed.path or "/").rstrip("/") or "/"
|
|
right_path = (right_parsed.path or "/").rstrip("/") or "/"
|
|
return left_path == right_path
|
|
|
|
|
|
def _has_candidate_bound_page_evidence(ctx: Any, candidate_url: str) -> bool:
|
|
inspection_count = int(getattr(ctx, "page_inspection_calls_this_turn", 0) or 0)
|
|
inspection_baseline = int(getattr(ctx, "resolved_discovery_entrypoint_inspection_baseline", 0) or 0)
|
|
if inspection_count <= inspection_baseline:
|
|
return False
|
|
evidence = getattr(ctx, "composition_page_evidence", None)
|
|
if not isinstance(evidence, dict):
|
|
return False
|
|
if evidence.get("source_tool") != "inspect_page_for_composition":
|
|
return False
|
|
for key in ("inspected_url", "current_url"):
|
|
value = evidence.get(key)
|
|
if isinstance(value, str) and _same_page(candidate_url, value):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _pre_discovery_url_question_nudge(
|
|
ctx: Any,
|
|
parsed: dict[str, Any],
|
|
config: CopilotConfig | None = None,
|
|
) -> str | None:
|
|
"""Steer the model to discovery when it asks before discovery has run.
|
|
|
|
INITIAL/DISCOVERING phase with zero discovery calls means the model went
|
|
straight to asking instead of resolving the entrypoint itself. Credential,
|
|
loop, and conditional clarifications carry a non-default
|
|
request_policy.clarification_reason and are let through; the structural
|
|
triple (phase + zero discovery calls + default clarification_reason) already
|
|
excludes them. The post-discovery could-not-resolve ask happens after
|
|
discovery ran (discovery_calls_this_turn > 0) and so never reaches this gate.
|
|
Steering any remaining pre-discovery ASK to discovery is correct: discovery
|
|
is cheap, and if the site cannot resolve the model re-asks afterward.
|
|
"""
|
|
if parsed.get("type") != "ASK_QUESTION":
|
|
return None
|
|
if getattr(ctx, "build_phase", None) not in DISCOVERY_PERMITTED_PHASES:
|
|
return None
|
|
if _get_int(ctx, "discovery_calls_this_turn") != 0:
|
|
return None
|
|
request_policy = getattr(ctx, "request_policy", None)
|
|
clarification_reason = getattr(request_policy, "clarification_reason", "none")
|
|
if clarification_reason not in (None, "none"):
|
|
return None
|
|
nudge_count = _get_int(ctx, "pre_discovery_url_question_nudge_count")
|
|
if nudge_count >= MAX_PRE_DISCOVERY_URL_QUESTION_NUDGES:
|
|
return None
|
|
ctx.pre_discovery_url_question_nudge_count = nudge_count + 1
|
|
LOG.info(
|
|
"copilot.pre_discovery_url_question_nudge",
|
|
reason_code="pre_discovery_url_question_steer_to_discovery",
|
|
build_phase=getattr(getattr(ctx, "build_phase", None), "value", None),
|
|
nudge_count=ctx.pre_discovery_url_question_nudge_count,
|
|
)
|
|
return _nudge(config, "pre_discovery_url_question")
|
|
|
|
|
|
def _post_discovery_entrypoint_url_question_nudge(
|
|
ctx: Any,
|
|
parsed: dict[str, Any],
|
|
config: CopilotConfig | None = None,
|
|
) -> str | None:
|
|
if parsed.get("type") != "ASK_QUESTION":
|
|
return None
|
|
candidate_url = getattr(ctx, "resolved_discovery_entrypoint_url", None)
|
|
failure_reason = getattr(ctx, "resolved_discovery_failure_reason", None)
|
|
if not isinstance(candidate_url, str) or not candidate_url or failure_reason:
|
|
return None
|
|
inspected_after_discovery = _has_candidate_bound_page_evidence(ctx, candidate_url)
|
|
mutated_after_discovery = bool(getattr(ctx, "update_workflow_called", False))
|
|
if inspected_after_discovery or mutated_after_discovery:
|
|
return None
|
|
nudge_count = getattr(ctx, "discovery_entrypoint_url_question_nudge_count", 0)
|
|
if nudge_count >= MAX_DISCOVERY_ENTRYPOINT_URL_QUESTION_NUDGES:
|
|
return None
|
|
ctx.discovery_entrypoint_url_question_nudge_count = nudge_count + 1
|
|
return f"{_nudge(config, 'post_discovery_entrypoint_url_question')} Resolved candidate_url: {candidate_url}"
|
|
|
|
|
|
def _response_coverage_nudge(ctx: Any, parsed: dict[str, Any], config: CopilotConfig | None = None) -> str | None:
|
|
"""Peek at the model's final output and return a nudge for coverage gaps
|
|
or progress-narration format. ASK_QUESTION is let through so the agent
|
|
can request missing credentials or disambiguation, except when discovery
|
|
resolved a candidate and the agent has not yet inspected or composed from
|
|
that candidate.
|
|
|
|
Returns the nudge string to inject, or None to let the response through.
|
|
"""
|
|
response_type = parsed.get("type")
|
|
pre_discovery_nudge = _pre_discovery_url_question_nudge(ctx, parsed, config)
|
|
if pre_discovery_nudge is not None:
|
|
return pre_discovery_nudge
|
|
|
|
discovery_entrypoint_nudge = _post_discovery_entrypoint_url_question_nudge(ctx, parsed, config)
|
|
if discovery_entrypoint_nudge is not None:
|
|
return discovery_entrypoint_nudge
|
|
|
|
present_contract_retry = _present_completion_contract_ask_retry(ctx, parsed)
|
|
if present_contract_retry is not None:
|
|
return present_contract_retry
|
|
|
|
if response_type not in ("REPLY", "REPLACE_WORKFLOW"):
|
|
return None
|
|
|
|
if (
|
|
response_type == "REPLY"
|
|
and not getattr(ctx, "update_workflow_called", False)
|
|
and looks_like_workflow_delivery_claim(parsed.get("user_response"))
|
|
):
|
|
nudge_count = getattr(ctx, "no_workflow_nudge_count", 0)
|
|
if nudge_count < MAX_NO_WORKFLOW_NUDGES:
|
|
ctx.no_workflow_nudge_count = nudge_count + 1
|
|
return _nudge(config, "post_no_workflow_delivery")
|
|
|
|
workflow_tested_ok = (
|
|
getattr(ctx, "last_test_ok", None) is True
|
|
and getattr(ctx, "update_workflow_called", False)
|
|
and getattr(ctx, "test_after_update_done", False)
|
|
)
|
|
if workflow_tested_ok:
|
|
block_count = getattr(ctx, "last_update_block_count", None)
|
|
# ctx.user_message is set by the agent orchestrator in a later stack PR
|
|
# (06c). The getattr default keeps this gate working on partial stacks.
|
|
user_message = getattr(ctx, "user_message", "")
|
|
completion_contract = _request_completion_contract(ctx)
|
|
if (
|
|
isinstance(block_count, int)
|
|
and not _completion_contract_unknown_due_to_policy_fallback(ctx)
|
|
and _goal_likely_needs_more_blocks(user_message, block_count, completion_contract)
|
|
):
|
|
nudge_count = getattr(ctx, "coverage_nudge_count", 0)
|
|
if nudge_count < MAX_INTERMEDIATE_NUDGES:
|
|
ctx.coverage_nudge_count = nudge_count + 1
|
|
return _nudge(config, "post_intermediate_success")
|
|
|
|
if _is_progress_narration(parsed.get("user_response")):
|
|
nudge_count = getattr(ctx, "format_nudge_count", 0)
|
|
if nudge_count < MAX_FORMAT_NUDGES:
|
|
ctx.format_nudge_count = nudge_count + 1
|
|
return _nudge(config, "post_format")
|
|
|
|
return None
|
|
|
|
|
|
def _consume_pending_screenshots(ctx: Any) -> dict[str, Any] | None:
|
|
"""Drain pending_screenshots into a synthetic user message with images.
|
|
|
|
Tool results stay text-only because OpenAI rejects images in tool
|
|
messages, so screenshots are delivered as a follow-up user message.
|
|
"""
|
|
pending = getattr(ctx, "pending_screenshots", None)
|
|
if not isinstance(pending, list) or not pending:
|
|
return None
|
|
screenshots: list[ScreenshotEntry] = list(pending)
|
|
pending.clear()
|
|
content: list[dict[str, Any]] = [
|
|
{
|
|
"type": "input_text",
|
|
"text": (
|
|
SCREENSHOT_SENTINEL + "Here is the screenshot from the tool result. "
|
|
"Analyze it to understand the current browser state."
|
|
),
|
|
},
|
|
]
|
|
for entry in screenshots:
|
|
content.append(
|
|
{
|
|
"type": "input_image",
|
|
"image_url": f"data:{entry.mime};base64,{entry.b64}",
|
|
"detail": "high",
|
|
}
|
|
)
|
|
return {"role": "user", "content": content}
|
|
|
|
|
|
def _needs_explore_without_workflow_nudge(ctx: Any) -> bool:
|
|
"""Return True when the agent navigated and observed but never engaged the workflow path."""
|
|
if not getattr(ctx, "navigate_called", False):
|
|
return False
|
|
if not getattr(ctx, "observation_after_navigate", False):
|
|
return False
|
|
if getattr(ctx, "update_workflow_called", False):
|
|
return False
|
|
if getattr(ctx, "test_after_update_done", False):
|
|
return False
|
|
nudge_count = getattr(ctx, "explore_without_workflow_nudge_count", 0)
|
|
return nudge_count < MAX_EXPLORE_WITHOUT_WORKFLOW_NUDGES
|
|
|
|
|
|
def _needs_failed_test_nudge(ctx: Any) -> bool:
|
|
"""Return True when the last test failed and the agent hasn't iterated yet."""
|
|
# A permanent nav error cannot be 'fix the workflow and retry' material —
|
|
# the dedicated non-retriable branch in _check_enforcement owns this case.
|
|
if getattr(ctx, "last_test_non_retriable_nav_error", None):
|
|
return False
|
|
if getattr(ctx, "pending_reconciliation_requires_user_input", False) is True:
|
|
return False
|
|
if getattr(ctx, "last_test_ok", None) is not False:
|
|
return False
|
|
if not getattr(ctx, "test_after_update_done", False):
|
|
return False
|
|
nudge_count = getattr(ctx, "failed_test_nudge_count", 0)
|
|
return nudge_count < MAX_FAILED_TEST_NUDGES
|
|
|
|
|
|
def _needs_inspect_before_repair_nudge(ctx: Any) -> bool:
|
|
"""True when a failed run is repairable and the reached page is not yet observed.
|
|
|
|
Routes the first post-failure move to observing the reached page before
|
|
re-authoring, instead of guessing a new block goal and re-running blind.
|
|
"""
|
|
contract = getattr(ctx, "latest_diagnosis_repair_contract", None)
|
|
if contract is None:
|
|
return False
|
|
if contract.repair_decision.next_action is not RepairNextAction.REPAIR:
|
|
return False
|
|
if not contract.diagnosis_input.browser_page_state.get("has_current_url"):
|
|
return False
|
|
return not _has_post_failed_run_page_observation(ctx)
|
|
|
|
|
|
def _has_post_failed_run_page_observation(ctx: AgentContext) -> bool:
|
|
if getattr(ctx, "post_run_page_observation_after_failed_test", False) is not True:
|
|
return False
|
|
tool = getattr(ctx, "post_run_page_observation_tool", None)
|
|
if tool not in _POST_RUN_PAGE_OBSERVATION_TOOLS:
|
|
return False
|
|
observed_run_id = getattr(ctx, "post_run_page_observation_workflow_run_id", None)
|
|
current_run_id = getattr(ctx, "last_run_blocks_workflow_run_id", None)
|
|
return bool(isinstance(observed_run_id, str) and observed_run_id and observed_run_id == current_run_id)
|
|
|
|
|
|
def _parse_normalized_final_response(result: RunResultStreaming | None) -> dict[str, Any] | None:
|
|
if result is None:
|
|
return None
|
|
parsed = parse_final_response(extract_final_text(result))
|
|
normalized_scaffolding = normalize_response_scaffolding(
|
|
str(parsed.get("type") or "REPLY"),
|
|
str(parsed.get("user_response") or ""),
|
|
)
|
|
if normalized_scaffolding.changed:
|
|
parsed = {
|
|
**parsed,
|
|
"type": normalized_scaffolding.response_type,
|
|
"user_response": normalized_scaffolding.user_response or "Done.",
|
|
}
|
|
return parsed
|
|
|
|
|
|
def _post_run_observed_reply_can_finalize(ctx: AgentContext, result: RunResultStreaming | None) -> bool:
|
|
if not _has_post_failed_run_page_observation(ctx):
|
|
return False
|
|
parsed = _parse_normalized_final_response(result)
|
|
if parsed is None or parsed.get("type") != "REPLY":
|
|
return False
|
|
user_response = parsed.get("user_response")
|
|
return isinstance(user_response, str) and bool(user_response.strip()) and not _is_progress_narration(user_response)
|
|
|
|
|
|
def _needs_suspicious_success_nudge(ctx: Any) -> bool:
|
|
"""Return True when the last test 'completed' but data blocks had no output."""
|
|
if _typed_terminal_challenge_outcome(ctx) is not None:
|
|
return False
|
|
# A non-retriable nav failure cannot be "suspiciously successful" — defer
|
|
# to the dedicated stop path rather than competing for the nudge slot.
|
|
if getattr(ctx, "last_test_non_retriable_nav_error", None):
|
|
return False
|
|
if not getattr(ctx, "last_test_suspicious_success", False):
|
|
return False
|
|
nudge_count = getattr(ctx, "suspicious_success_nudge_count", 0)
|
|
return nudge_count < MAX_SUSPICIOUS_SUCCESS_NUDGES
|
|
|
|
|
|
def _needs_per_tool_budget_nudge(ctx: Any) -> bool:
|
|
if getattr(ctx, "last_failure_category_top", None) != PER_TOOL_BUDGET_FAILURE_CATEGORY:
|
|
return False
|
|
return _get_int(ctx, "per_tool_budget_nudge_count") < MAX_PER_TOOL_BUDGET_NUDGES
|
|
|
|
|
|
def _needs_probable_site_block_stop_nudge(ctx: Any) -> bool:
|
|
"""Return True when the site-block-wall streak has reached the stop level
|
|
AND the per-streak nudge cap has not been exhausted."""
|
|
if _get_int(ctx, "probable_site_block_streak_count") < PROBABLE_SITE_BLOCK_STREAK_STOP_AT:
|
|
return False
|
|
return _get_int(ctx, "probable_site_block_stop_nudge_count") < MAX_PROBABLE_SITE_BLOCK_STOP_NUDGES
|
|
|
|
|
|
def _get_int(ctx: Any, name: str, default: int = 0) -> int:
|
|
value = getattr(ctx, name, default)
|
|
return value if isinstance(value, int) else default
|
|
|
|
|
|
def _repeated_frontier_failure_nudge(ctx: Any, config: CopilotConfig | None = None) -> str | None:
|
|
"""Emit each escalation level at most once per streak. The streak itself
|
|
keeps climbing on further identical failures (incremented elsewhere by
|
|
update_repeated_failure_state), so the stop nudge fires naturally on the
|
|
next repeat after a warn."""
|
|
# Non-retriable nav errors get their own dedicated stop path; don't let a
|
|
# repeated-frontier nudge smuggle different retry advice past the gate.
|
|
if getattr(ctx, "last_test_non_retriable_nav_error", None):
|
|
return None
|
|
# Defer to the probable-site-block stop path once the wall has been
|
|
# confirmed across ≥ PROBABLE_SITE_BLOCK_STREAK_STOP_AT shape-independent
|
|
# attempts — at that point "try yet another shape" is empirically wrong.
|
|
if _get_int(ctx, "probable_site_block_streak_count") >= PROBABLE_SITE_BLOCK_STREAK_STOP_AT:
|
|
return None
|
|
streak = _get_int(ctx, "repeated_failure_streak_count")
|
|
emitted = _get_int(ctx, "repeated_failure_nudge_emitted_at_streak")
|
|
top_category = getattr(ctx, "last_failure_category_top", None)
|
|
is_param_binding = top_category == "PARAMETER_BINDING_ERROR"
|
|
|
|
if streak >= REPEATED_FRONTIER_STREAK_STOP_AT and emitted < REPEATED_FRONTIER_STREAK_STOP_AT:
|
|
return _nudge(
|
|
config,
|
|
"post_parameter_binding_stop" if is_param_binding else "post_repeated_frontier_failure_stop",
|
|
)
|
|
if streak >= REPEATED_FRONTIER_STREAK_ESCALATE_AT and emitted < REPEATED_FRONTIER_STREAK_ESCALATE_AT:
|
|
return _nudge(
|
|
config,
|
|
"post_parameter_binding_warn" if is_param_binding else "post_repeated_frontier_failure_warn",
|
|
)
|
|
return None
|
|
|
|
|
|
def _is_stop_level_frontier_nudge(nudge: str, config: CopilotConfig | None = None) -> bool:
|
|
return nudge in {
|
|
_nudge(config, "post_repeated_frontier_failure_stop"),
|
|
_nudge(config, "post_parameter_binding_stop"),
|
|
}
|
|
|
|
|
|
def _non_retriable_nav_error_nudge(ctx: Any, config: CopilotConfig | None = None) -> tuple[str, str] | None:
|
|
"""Emit POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE at most once per distinct
|
|
non-retriable nav-error signature. Returns ``(nudge, signature)`` when it
|
|
should fire, ``None`` otherwise. Signature normalization is shared with
|
|
`failure_tracking.compute_failure_signature`, so a cert error after a DNS
|
|
error (or vice versa) counts as a distinct signature and re-fires."""
|
|
raw = getattr(ctx, "last_test_non_retriable_nav_error", None)
|
|
if not isinstance(raw, str) or not raw:
|
|
return None
|
|
signature = normalize_failure_reason(raw)
|
|
last_emitted = getattr(ctx, "non_retriable_nav_error_last_emitted_signature", None)
|
|
if signature == last_emitted:
|
|
return None
|
|
return _nudge(config, "post_non_retriable_nav_error_stop"), signature
|
|
|
|
|
|
def _check_enforcement(
|
|
ctx: Any,
|
|
result: RunResultStreaming | None = None,
|
|
config: CopilotConfig | None = None,
|
|
) -> str | None:
|
|
verified = outcome_fully_verified(ctx)
|
|
# Terminal failure-mode signals must pre-empt tool-call hygiene nudges.
|
|
terminal_signal = getattr(ctx, "latest_tool_blocker_signal", None) or getattr(ctx, "blocker_signal", None)
|
|
if terminal_signal is not None:
|
|
stash_turn_halt_from_blocker_signal(ctx, terminal_signal, source="enforcement_backstop")
|
|
raise_if_turn_halt(ctx, verified=verified)
|
|
_raise_if_unrecoverable_contract_stop(ctx)
|
|
|
|
if verified_goal_satisfied_context(ctx):
|
|
raise CopilotGoalSatisfied()
|
|
if built_unverified_repair_inert_context(ctx):
|
|
raise CopilotBuiltUnverified()
|
|
|
|
if _needs_repair_ceiling_halt(ctx):
|
|
contract = getattr(ctx, "latest_diagnosis_repair_contract", None)
|
|
state = _repair_loop_state(ctx)
|
|
# Backstop: detection-time latch normally raises this above; catches a run-backed increment that bypassed it.
|
|
signal = repair_ceiling_stop_signal(ctx, contract, config)
|
|
stash_blocker_signal(ctx, signal)
|
|
stash_repair_ceiling_turn_halt(
|
|
ctx,
|
|
signal,
|
|
consecutive_identical_repair_count=(state.consecutive_identical_repair_count if state is not None else 0),
|
|
)
|
|
raise_if_turn_halt(ctx, verified=verified)
|
|
|
|
# A permanent navigation error (DNS / cert / SSL / invalid URL) cannot be
|
|
# resolved by observing a prior navigate or by testing an updated
|
|
# workflow against the same bad URL, so let it speak first.
|
|
non_retriable = _non_retriable_nav_error_nudge(ctx, config)
|
|
if non_retriable is not None:
|
|
nudge_msg, signature = non_retriable
|
|
ctx.non_retriable_nav_error_last_emitted_signature = signature
|
|
return nudge_msg
|
|
|
|
if ctx.navigate_called and not ctx.observation_after_navigate and not ctx.navigate_enforcement_done:
|
|
ctx.navigate_enforcement_done = True
|
|
return _nudge(config, "post_navigate")
|
|
|
|
if _needs_explore_without_workflow_nudge(ctx):
|
|
ctx.explore_without_workflow_nudge_count += 1
|
|
return _nudge(config, "post_explore_without_workflow")
|
|
|
|
if (
|
|
ctx.update_workflow_called
|
|
and not ctx.test_after_update_done
|
|
and getattr(ctx, "allow_untested_workflow_draft", False) is not True
|
|
):
|
|
return _nudge(config, "post_update")
|
|
|
|
if _post_run_observed_reply_can_finalize(ctx, result):
|
|
return None
|
|
|
|
_maybe_stash_terminal_challenge_halt(ctx)
|
|
raise_if_turn_halt(ctx, verified=verified)
|
|
|
|
# If the last run had confirmed challenge evidence, do not misdiagnose a
|
|
# challenge-solving loop as a long-chain budgeting problem.
|
|
if _needs_failed_test_nudge(ctx) and getattr(ctx, "last_test_anti_bot", None):
|
|
ctx.failed_test_nudge_count += 1
|
|
return _nudge(config, "post_anti_bot_failed_test")
|
|
|
|
# A budget-trip without challenge evidence is a structural problem (chain
|
|
# too long), not a workflow-shape problem — emit the targeted "split the
|
|
# chain" advice before the generic repeated-frontier and failed-test paths
|
|
# can fire.
|
|
if _needs_per_tool_budget_nudge(ctx):
|
|
prior = _get_int(ctx, "per_tool_budget_nudge_count")
|
|
ctx.per_tool_budget_nudge_count = prior + 1
|
|
# First budget trip earns one smaller-frontier retry. A second consecutive trip
|
|
# (the shrunk frontier ALSO blew the budget) is a doomed shrinking-budget spiral on a
|
|
# too-heavy page — finalize the verified prefix instead of re-running into less time.
|
|
if prior >= 1:
|
|
return _nudge(config, "post_per_tool_budget_stop")
|
|
return _nudge(config, "post_per_tool_budget")
|
|
|
|
repeated_frontier_nudge = _repeated_frontier_failure_nudge(ctx, config)
|
|
if repeated_frontier_nudge is not None:
|
|
# Latch the emitted level so each escalation fires at most once per streak.
|
|
ctx.repeated_failure_nudge_emitted_at_streak = (
|
|
REPEATED_FRONTIER_STREAK_STOP_AT
|
|
if _is_stop_level_frontier_nudge(repeated_frontier_nudge, config)
|
|
else REPEATED_FRONTIER_STREAK_ESCALATE_AT
|
|
)
|
|
return repeated_frontier_nudge
|
|
|
|
# Do NOT clear last_test_suspicious_success here. tools._record_run_blocks_result
|
|
# resets it on every new run; if the agent ignores the nudge and answers
|
|
# without rerunning, we want _check_enforcement to re-emit the nudge.
|
|
if _needs_suspicious_success_nudge(ctx):
|
|
ctx.suspicious_success_nudge_count = getattr(ctx, "suspicious_success_nudge_count", 0) + 1
|
|
return _nudge(config, "post_suspicious_success")
|
|
|
|
# Checked before the generic failed-test nudge so a scrape-wall streak
|
|
# emits the specific STOP text and does not also consume a
|
|
# failed_test_nudge_count slot.
|
|
if _needs_probable_site_block_stop_nudge(ctx):
|
|
ctx.probable_site_block_stop_nudge_count = getattr(ctx, "probable_site_block_stop_nudge_count", 0) + 1
|
|
signal = _probable_site_block_stop_signal(ctx, config)
|
|
stash_blocker_signal(ctx, signal)
|
|
stash_turn_halt_from_blocker_signal(ctx, signal, source="enforcement")
|
|
raise_if_turn_halt(ctx, verified=verified)
|
|
|
|
if _needs_failed_test_nudge(ctx):
|
|
ctx.failed_test_nudge_count += 1
|
|
if _needs_inspect_before_repair_nudge(ctx):
|
|
return _nudge(config, "post_failed_test_inspect_first")
|
|
return _nudge(config, "post_failed_test")
|
|
|
|
# The convergence floor for code-authoring guardrail churn. It is the last
|
|
# halt so every genuinely-terminal stop and recoverable nudge above gets its
|
|
# turn first. The permanent non-retriable nav raise is the one stop ordering
|
|
# cannot front-run (it lives after _check_enforcement returns None, at the
|
|
# run_with_enforcement return sites), so the floor yields to it explicitly.
|
|
if not getattr(ctx, "last_test_non_retriable_nav_error", None):
|
|
churn_signal = _churn_signal_if_halting(ctx)
|
|
if churn_signal is not None:
|
|
stash_blocker_signal(ctx, churn_signal)
|
|
stash_turn_halt_from_blocker_signal(ctx, churn_signal, source="enforcement_backstop")
|
|
raise_if_turn_halt(ctx)
|
|
if _needs_no_progress_interaction_halt(ctx):
|
|
no_progress_signal = no_forward_progress_interaction_stop_signal(ctx)
|
|
stash_blocker_signal(ctx, no_progress_signal)
|
|
stash_turn_halt_from_blocker_signal(ctx, no_progress_signal, source="enforcement_backstop")
|
|
raise_if_turn_halt(ctx)
|
|
|
|
# Response-time gate: peek at the model's final output to tell ASK_QUESTION
|
|
# (always allowed) from a REPLY with a coverage gap or progress-narration.
|
|
# Only runs when no state-based nudge fired.
|
|
if result is not None:
|
|
parsed = _parse_normalized_final_response(result)
|
|
if parsed is None:
|
|
return None
|
|
return _response_coverage_nudge(ctx, parsed, config)
|
|
|
|
return None
|
|
|
|
|
|
def _item_field(item: Any, name: str) -> Any:
|
|
"""Read *name* from an item that can be either a dict or an attr-style object."""
|
|
if isinstance(item, dict):
|
|
return item.get(name)
|
|
return getattr(item, name, None)
|
|
|
|
|
|
def is_screenshot_message(item: Any) -> bool:
|
|
"""Return True if the item is a synthetic screenshot user message."""
|
|
if _item_field(item, "role") != "user":
|
|
return False
|
|
content = _item_field(item, "content")
|
|
if isinstance(content, str):
|
|
return content.startswith(SCREENSHOT_SENTINEL)
|
|
if not isinstance(content, list):
|
|
return False
|
|
for block in content:
|
|
text = _item_field(block, "text")
|
|
if isinstance(text, str) and text.startswith(SCREENSHOT_SENTINEL):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_nudge_message(item: Any) -> bool:
|
|
"""Return True if the item is a synthetic enforcement nudge."""
|
|
if _item_field(item, "role") != "user":
|
|
return False
|
|
content = _item_field(item, "content")
|
|
return isinstance(content, str) and content.startswith(NUDGE_SENTINEL)
|
|
|
|
|
|
def is_synthetic_user_message(item: Any) -> bool:
|
|
"""Return True if item is a screenshot or nudge (not a real user turn)."""
|
|
return is_screenshot_message(item) or _is_nudge_message(item)
|
|
|
|
|
|
def _truncated_output_fallback(output: str) -> str:
|
|
return output[:_TOOL_OUTPUT_SUMMARIZE_THRESHOLD] + _TOOL_OUTPUT_TRUNCATION_SUFFIX
|
|
|
|
|
|
def _summarize_tool_output(output: str) -> str:
|
|
"""Compress an old function_call_output to a compact JSON synopsis that
|
|
preserves only signal fields (ok/error/status/failure_reason/block labels).
|
|
Falls back to a head-truncation when the output isn't a JSON dict."""
|
|
if not isinstance(output, str) or len(output) <= _TOOL_OUTPUT_SUMMARIZE_THRESHOLD:
|
|
return output
|
|
|
|
try:
|
|
parsed = json.loads(output)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return _truncated_output_fallback(output)
|
|
|
|
if not isinstance(parsed, dict):
|
|
return _truncated_output_fallback(output)
|
|
|
|
synopsis: dict[str, Any] = {}
|
|
if "ok" in parsed:
|
|
synopsis["ok"] = parsed["ok"]
|
|
if parsed.get("error"):
|
|
synopsis["error"] = str(parsed["error"])[:200]
|
|
|
|
data = parsed.get("data")
|
|
if isinstance(data, dict):
|
|
for key in ("overall_status", "workflow_run_id", "failure_reason", "url", "message"):
|
|
val = data.get(key)
|
|
if val is None or val == "":
|
|
continue
|
|
synopsis[key] = val if isinstance(val, (bool, int, float)) else str(val)[:200]
|
|
|
|
# Preserve failure_categories — tools._record_run_blocks_result injects
|
|
# these specifically for downstream reasoning about why a test failed.
|
|
categories = data.get("failure_categories")
|
|
if isinstance(categories, list) and categories:
|
|
synopsis["failure_categories"] = categories
|
|
|
|
blocks = data.get("blocks")
|
|
if isinstance(blocks, list):
|
|
block_summary: list[dict[str, Any]] = []
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
entry: dict[str, Any] = {"label": block.get("label"), "status": block.get("status")}
|
|
if block.get("failure_reason"):
|
|
entry["failure_reason"] = str(block["failure_reason"])[:120]
|
|
block_summary.append(entry)
|
|
if block_summary:
|
|
synopsis["blocks"] = block_summary
|
|
|
|
synopsis["_summarized"] = "older tool output — only key fields retained"
|
|
try:
|
|
return json.dumps(synopsis, separators=(",", ":"))
|
|
except (TypeError, ValueError):
|
|
return _truncated_output_fallback(output)
|
|
|
|
|
|
def _replace_item_field(item: Any, name: str, new_value: Any) -> Any:
|
|
"""Return a copy of *item* with its *name* field replaced.
|
|
|
|
For dicts and attr-style objects, always returns a new object — never
|
|
mutates *item* in place. `_prune_input_list` runs over input lists that
|
|
may share references with SDK-owned state (e.g. `result.to_input_list()`
|
|
and `model_data.input`); in-place mutation there would corrupt shared
|
|
state.
|
|
"""
|
|
if isinstance(item, dict):
|
|
return {**item, name: new_value}
|
|
try:
|
|
dup = copy.copy(item)
|
|
setattr(dup, name, new_value)
|
|
return dup
|
|
except (AttributeError, TypeError) as exc:
|
|
LOG.debug(
|
|
"Could not rewrite input-list item field; leaving untouched",
|
|
field=name,
|
|
item_type=type(item).__name__,
|
|
error=str(exc),
|
|
)
|
|
return item
|
|
|
|
|
|
def _replace_item_output(item: Any, new_output: str) -> Any:
|
|
return _replace_item_field(item, "output", new_output)
|
|
|
|
|
|
def _summarize_tool_arguments(args_json: str) -> str:
|
|
"""Compact the arguments payload of an older tool call so that massive
|
|
inputs (e.g. the full workflow YAML passed to `update_workflow`) don't keep
|
|
bloating replayed context. Short payloads pass through unchanged."""
|
|
if len(args_json) <= _TOOL_OUTPUT_SUMMARIZE_THRESHOLD:
|
|
return args_json
|
|
try:
|
|
parsed = json.loads(args_json)
|
|
except (TypeError, ValueError):
|
|
return args_json[:_RECENT_TOOL_OUTPUT_CHAR_CAP] + _TOOL_OUTPUT_TRUNCATION_SUFFIX
|
|
if not isinstance(parsed, dict):
|
|
return args_json[:_RECENT_TOOL_OUTPUT_CHAR_CAP] + _TOOL_OUTPUT_TRUNCATION_SUFFIX
|
|
compact: dict[str, Any] = {}
|
|
for key, val in parsed.items():
|
|
if isinstance(val, str) and len(val) > 500:
|
|
compact[key] = f"<{key} truncated: {len(val)} chars>"
|
|
elif isinstance(val, (list, dict)):
|
|
serialized = json.dumps(val, separators=(",", ":"), default=str)
|
|
compact[key] = f"<{key} truncated: {len(serialized)} chars>" if len(serialized) > 500 else val
|
|
else:
|
|
compact[key] = val
|
|
compact["_summarized"] = "older tool call — large fields replaced with size markers"
|
|
try:
|
|
return json.dumps(compact, separators=(",", ":"))
|
|
except (TypeError, ValueError):
|
|
return args_json[:_RECENT_TOOL_OUTPUT_CHAR_CAP] + _TOOL_OUTPUT_TRUNCATION_SUFFIX
|
|
|
|
|
|
def _prune_input_list(items: list[Any]) -> list[Any]:
|
|
"""Drop all but the most recent screenshot, compress older tool outputs,
|
|
and summarize the arguments of older tool CALLS so bulky payloads (like
|
|
the full workflow YAML) don't accumulate in replayed context.
|
|
|
|
Screenshots collapse to a short text placeholder. function_call_output and
|
|
function_call items keep the last KEEP_RECENT_TOOL_OUTPUTS at full size
|
|
(head-truncated); older ones collapse to JSON synopses.
|
|
"""
|
|
screenshot_indices = [i for i, item in enumerate(items) if is_screenshot_message(item)]
|
|
drop_indices = set(screenshot_indices[:-1])
|
|
|
|
fco_indices = [i for i, item in enumerate(items) if _item_field(item, "type") == "function_call_output"]
|
|
recent_fco_set = set(fco_indices[-KEEP_RECENT_TOOL_OUTPUTS:])
|
|
|
|
fc_indices = [i for i, item in enumerate(items) if _item_field(item, "type") == "function_call"]
|
|
recent_fc_set = set(fc_indices[-KEEP_RECENT_TOOL_OUTPUTS:])
|
|
|
|
result: list[Any] = []
|
|
for i, item in enumerate(items):
|
|
if i in drop_indices:
|
|
result.append({"role": "user", "content": SCREENSHOT_PLACEHOLDER})
|
|
continue
|
|
|
|
item_type = _item_field(item, "type")
|
|
if item_type == "function_call_output":
|
|
output = _item_field(item, "output")
|
|
if isinstance(output, str):
|
|
if i in recent_fco_set:
|
|
new_output = (
|
|
output[:_RECENT_TOOL_OUTPUT_CHAR_CAP] + _TOOL_OUTPUT_HEAD_TRUNCATION_SUFFIX
|
|
if len(output) > _RECENT_TOOL_OUTPUT_CHAR_CAP
|
|
else output
|
|
)
|
|
else:
|
|
new_output = _summarize_tool_output(output)
|
|
if new_output != output:
|
|
item = _replace_item_output(item, new_output)
|
|
elif item_type == "function_call" and i not in recent_fc_set:
|
|
args = _item_field(item, "arguments")
|
|
if isinstance(args, str):
|
|
new_args = _summarize_tool_arguments(args)
|
|
if new_args != args:
|
|
item = _replace_item_field(item, "arguments", new_args)
|
|
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _sanitize_for_token_estimation(value: Any) -> tuple[Any, int]:
|
|
"""Build a sanitized copy of *value*, replacing base64 image data with
|
|
a short placeholder so blobs don't inflate the token count.
|
|
|
|
Returns ``(sanitized_value, image_count)``.
|
|
"""
|
|
if isinstance(value, dict):
|
|
is_image = value.get("type") == "input_image"
|
|
sanitized: dict[str, Any] = {}
|
|
image_count = 1 if is_image else 0
|
|
for key, child in value.items():
|
|
if is_image and key == "image_url":
|
|
sanitized[key] = "[image]"
|
|
continue
|
|
sanitized_child, child_images = _sanitize_for_token_estimation(child)
|
|
sanitized[key] = sanitized_child
|
|
image_count += child_images
|
|
return sanitized, image_count
|
|
if isinstance(value, list):
|
|
sanitized_list: list[Any] = []
|
|
image_count = 0
|
|
for item in value:
|
|
sanitized_item, item_images = _sanitize_for_token_estimation(item)
|
|
sanitized_list.append(sanitized_item)
|
|
image_count += item_images
|
|
return sanitized_list, image_count
|
|
return value, 0
|
|
|
|
|
|
def estimate_tokens(items: list[Any]) -> int:
|
|
"""Token estimate for an input list using tiktoken."""
|
|
if not items:
|
|
return 0
|
|
sanitized, image_count = _sanitize_for_token_estimation(items)
|
|
text = json.dumps(sanitized, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
return count_tokens(text) + image_count * TOKENS_PER_RESIZED_IMAGE
|
|
|
|
|
|
_AGGRESSIVE_PRUNE_TAIL = 7
|
|
|
|
|
|
def aggressive_prune(items: list[Any]) -> list[Any]:
|
|
"""Emergency prune: drop ALL screenshots, keep original message + last ~3
|
|
tool call/output pairs + latest nudge."""
|
|
if not items:
|
|
return items
|
|
|
|
tail: list[Any] = []
|
|
for item in reversed(items[1:]):
|
|
if is_screenshot_message(item):
|
|
continue
|
|
tail.append(item)
|
|
if len(tail) >= _AGGRESSIVE_PRUNE_TAIL:
|
|
break
|
|
tail.reverse()
|
|
return [items[0]] + tail
|
|
|
|
|
|
def _is_context_window_error(exc: BaseException) -> bool:
|
|
msg = str(exc).lower()
|
|
# Match OpenAI's explicit code/phrase variants. Avoid loose substrings like
|
|
# "max_tokens" which also appear in max_tokens_per_request quota errors.
|
|
return (
|
|
"context_length_exceeded" in msg
|
|
or "context window" in msg
|
|
or "maximum context length" in msg
|
|
or "reduce the length of the messages" in msg
|
|
)
|
|
|
|
|
|
_NUDGE_TYPE_BY_MESSAGE: dict[str, str] = {
|
|
POST_UPDATE_NUDGE: "post_update",
|
|
POST_NAVIGATE_NUDGE: "post_navigate",
|
|
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE: "explore_without_workflow",
|
|
POST_SUSPICIOUS_SUCCESS_NUDGE: "suspicious_success",
|
|
POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE: "repeated_frontier_failure_warn",
|
|
POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE: "repeated_frontier_failure_stop",
|
|
POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE: "non_retriable_nav_error_stop",
|
|
POST_PARAMETER_BINDING_WARN_NUDGE: "parameter_binding_warn",
|
|
POST_PARAMETER_BINDING_STOP_NUDGE: "parameter_binding_stop",
|
|
POST_ANTI_BOT_FAILED_TEST_NUDGE: "anti_bot_block",
|
|
POST_PROBABLE_SITE_BLOCK_STOP_NUDGE: "probable_site_block_stop",
|
|
POST_PER_TOOL_BUDGET_NUDGE: "per_tool_budget_split",
|
|
POST_PER_TOOL_BUDGET_STOP_NUDGE: "per_tool_budget_stop",
|
|
POST_NO_WORKFLOW_DELIVERY_NUDGE: "no_workflow_delivery",
|
|
POST_DISCOVERY_ENTRYPOINT_URL_QUESTION_NUDGE: "discovery_entrypoint_url_question",
|
|
PRE_DISCOVERY_URL_QUESTION_NUDGE: "pre_discovery_url_question",
|
|
POST_FAILED_TEST_NUDGE: "post_failed_test",
|
|
POST_FAILED_TEST_INSPECT_FIRST_NUDGE: "post_failed_test_inspect_first",
|
|
SCREENSHOT_DROPPED_NUDGE: "screenshot_dropped_on_recovery",
|
|
}
|
|
|
|
|
|
_NUDGE_TYPE_BY_KEY: dict[str, str] = {
|
|
"post_update": "post_update",
|
|
"post_navigate": "post_navigate",
|
|
"post_explore_without_workflow": "explore_without_workflow",
|
|
"post_suspicious_success": "suspicious_success",
|
|
"post_repeated_frontier_failure_warn": "repeated_frontier_failure_warn",
|
|
"post_repeated_frontier_failure_stop": "repeated_frontier_failure_stop",
|
|
"post_non_retriable_nav_error_stop": "non_retriable_nav_error_stop",
|
|
"post_parameter_binding_warn": "parameter_binding_warn",
|
|
"post_parameter_binding_stop": "parameter_binding_stop",
|
|
"post_anti_bot_failed_test": "anti_bot_block",
|
|
"post_probable_site_block_stop": "probable_site_block_stop",
|
|
"post_probable_site_block_stop_prefix": "probable_site_block_stop",
|
|
"post_per_tool_budget": "per_tool_budget_split",
|
|
"post_per_tool_budget_stop": "per_tool_budget_stop",
|
|
"post_no_workflow_delivery": "no_workflow_delivery",
|
|
"post_discovery_entrypoint_url_question": "discovery_entrypoint_url_question",
|
|
"pre_discovery_url_question": "pre_discovery_url_question",
|
|
"post_failed_test": "post_failed_test",
|
|
"post_failed_test_inspect_first": "post_failed_test_inspect_first",
|
|
"screenshot_dropped": "screenshot_dropped_on_recovery",
|
|
"post_intermediate_success": "intermediate_success",
|
|
"post_format": "format",
|
|
}
|
|
|
|
|
|
def _nudge_type_for_log(nudge: str, config: CopilotConfig | None = None) -> str:
|
|
nudge_by_key = config.enforcement_nudges if config is not None else DEFAULT_ENFORCEMENT_NUDGES
|
|
if nudge.startswith(nudge_by_key["post_probable_site_block_stop_prefix"]):
|
|
return "probable_site_block_stop"
|
|
for key, value in nudge_by_key.items():
|
|
if value == nudge:
|
|
return _NUDGE_TYPE_BY_KEY.get(key, key)
|
|
return _NUDGE_TYPE_BY_MESSAGE.get(nudge, "intermediate_success")
|
|
|
|
|
|
def _strip_input_images(current_input: str | list) -> tuple[str | list, bool]:
|
|
"""Replace ``input_image`` parts in *current_input* with a text placeholder.
|
|
|
|
Used on context-overflow retry to ensure a freshly injected screenshot
|
|
payload doesn't re-trigger the same failure. Returns ``(pruned, stripped)``
|
|
where ``stripped`` is True iff at least one image was removed — the caller
|
|
uses that to warn the agent not to reason about the page from memory.
|
|
"""
|
|
if not isinstance(current_input, list):
|
|
return current_input, False
|
|
stripped_any = False
|
|
result: list[Any] = []
|
|
for item in current_input:
|
|
if not isinstance(item, dict):
|
|
result.append(item)
|
|
continue
|
|
content = item.get("content")
|
|
if not isinstance(content, list):
|
|
result.append(item)
|
|
continue
|
|
new_content: list[Any] = []
|
|
for part in content:
|
|
if isinstance(part, dict) and part.get("type") == "input_image":
|
|
new_content.append({"type": "input_text", "text": SCREENSHOT_PLACEHOLDER})
|
|
stripped_any = True
|
|
else:
|
|
new_content.append(part)
|
|
result.append({**item, "content": new_content})
|
|
return result, stripped_any
|
|
|
|
|
|
async def _recover_from_context_overflow(session: Any, current_input: str | list) -> tuple[str | list, bool]:
|
|
"""Aggressively prune the working context (session + current turn input) so
|
|
the next Runner.run_streamed call fits within the context window.
|
|
|
|
Strips images from *current_input* regardless of session state: a freshly
|
|
injected screenshot payload is the most likely cause of overflow on the
|
|
session-backed path, where session history is already filter-bounded.
|
|
|
|
Returns ``(recovered_input, images_stripped)``.
|
|
"""
|
|
stripped_any = False
|
|
stripped_input: str | list
|
|
if isinstance(current_input, list):
|
|
image_free, stripped_any = _strip_input_images(current_input)
|
|
if isinstance(image_free, list) and session is None:
|
|
stripped_input = aggressive_prune(image_free)
|
|
else:
|
|
stripped_input = image_free
|
|
else:
|
|
stripped_input = current_input
|
|
|
|
if session is not None:
|
|
all_items = await session.get_items()
|
|
pruned = aggressive_prune(all_items)
|
|
await session.clear_session()
|
|
await session.add_items(pruned)
|
|
return stripped_input, stripped_any
|
|
if isinstance(stripped_input, list):
|
|
return stripped_input, stripped_any
|
|
raise RuntimeError("Cannot recover from context overflow: no session and input is not a list")
|
|
|
|
|
|
class _SendTrackingStream:
|
|
"""Wraps EventSourceStream to report whether any frame was sent.
|
|
|
|
Used to decide whether an overflow-retry would duplicate SSE frames: if
|
|
the provider raises before the first successful ``.send()``, retry is
|
|
safe. Otherwise the client has already seen partial output and the caller
|
|
must re-raise rather than retry.
|
|
"""
|
|
|
|
def __init__(self, inner: EventSourceStream) -> None:
|
|
self._inner = inner
|
|
self.emitted = False
|
|
|
|
async def send(self, data: Any) -> bool:
|
|
ok = await self._inner.send(data)
|
|
if ok:
|
|
self.emitted = True
|
|
return ok
|
|
|
|
async def is_disconnected(self) -> bool:
|
|
return await self._inner.is_disconnected()
|
|
|
|
async def close(self) -> None:
|
|
await self._inner.close()
|
|
|
|
|
|
def _accumulate_usage(result: RunResultStreaming, ctx: Any) -> None:
|
|
"""Sum the SDK's per-iteration usage into ``ctx``.
|
|
|
|
The SDK aggregates usage into ``context_wrapper.usage`` before tool execution,
|
|
so prior-turn tokens survive a mid-tool abort; each ``Runner.run_streamed``
|
|
call gets a fresh wrapper, so totals must accumulate on ``ctx`` across
|
|
iterations rather than overwrite.
|
|
"""
|
|
if not hasattr(ctx, "total_tokens_used"):
|
|
return
|
|
usage = getattr(getattr(result, "context_wrapper", None), "usage", None)
|
|
if usage is None:
|
|
return
|
|
|
|
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
total_tokens = getattr(usage, "total_tokens", 0) or 0
|
|
|
|
if not (input_tokens or output_tokens or total_tokens):
|
|
return
|
|
|
|
ctx.input_tokens_used = (ctx.input_tokens_used or 0) + input_tokens
|
|
ctx.output_tokens_used = (ctx.output_tokens_used or 0) + output_tokens
|
|
ctx.total_tokens_used = (ctx.total_tokens_used or 0) + total_tokens
|
|
|
|
|
|
async def _run_streamed_with_deadline(
|
|
agent: Agent,
|
|
current_input: str | list,
|
|
ctx: Any,
|
|
session: Any,
|
|
tracked_stream: _SendTrackingStream,
|
|
runner_kwargs: dict[str, Any],
|
|
start_time: float,
|
|
iteration: int,
|
|
) -> Any:
|
|
"""Run ``Runner.run_streamed`` + ``stream_to_sse`` with a deadline
|
|
against ``TOTAL_TIMEOUT_SECONDS``.
|
|
|
|
The top-of-loop elapsed check only fires between iterations; a
|
|
long-running tool inside ``Runner.run_streamed`` needs ``wait_for``
|
|
to raise ``CopilotTotalTimeoutError`` mid-tool so the caller's
|
|
``_build_exit_result`` path emits a non-empty REPLY before the
|
|
client's own transport timeout closes the stream.
|
|
|
|
``max(1.0, ...)`` floors ``remaining`` so ``wait_for(timeout=0)``
|
|
never panics on an already-spent budget.
|
|
"""
|
|
from skyvern.forge.sdk.copilot.streaming_adapter import stream_to_sse
|
|
|
|
elapsed = time.monotonic() - start_time
|
|
remaining = max(1.0, TOTAL_TIMEOUT_SECONDS - elapsed)
|
|
result = Runner.run_streamed(agent, input=current_input, context=ctx, session=session, **runner_kwargs)
|
|
try:
|
|
try:
|
|
await asyncio.wait_for(stream_to_sse(result, tracked_stream, ctx), timeout=remaining)
|
|
finally:
|
|
_accumulate_usage(result, ctx)
|
|
except asyncio.TimeoutError:
|
|
_mark_copilot_total_timeout(ctx)
|
|
LOG.warning(
|
|
"Copilot total timeout exceeded mid-iteration",
|
|
elapsed_seconds=round(time.monotonic() - start_time, 3),
|
|
iteration=iteration,
|
|
)
|
|
raise CopilotTotalTimeoutError() from None
|
|
return result
|
|
|
|
|
|
def _maybe_synthesized_block_offer_msg(ctx: Any) -> dict[str, Any] | None:
|
|
"""Post-turn fallback offer of a deterministically synthesized code block, in code-only mode.
|
|
|
|
Returns a single user message wrapping the synthesized Playwright block, or
|
|
None when the policy/latch/empty-trajectory guards do not hold. Shares the
|
|
latch with the pre-authoring prompt-side offer. The initial offer suppresses
|
|
near-duplicate repeats, but a materially longer scout trajectory can refresh
|
|
the deterministic code before the model authors the workflow.
|
|
"""
|
|
reopened_after_failed_run = synthesized_persistence_reopened_after_failed_run(ctx)
|
|
reopened = synthesized_persistence_reopened(ctx)
|
|
if getattr(ctx, "update_workflow_called", False) and not reopened:
|
|
return None
|
|
if normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)) != (
|
|
BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
):
|
|
return None
|
|
trajectory = getattr(ctx, "scout_trajectory", None) or []
|
|
if not trajectory:
|
|
return None
|
|
if is_optional_dismissal_only_trajectory(trajectory):
|
|
return None
|
|
trajectory_len = len(trajectory)
|
|
previous_offer_len = getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0
|
|
trajectory_goal_complete = synthesized_trajectory_is_goal_complete(ctx)
|
|
if (
|
|
getattr(ctx, "synthesized_block_offered", False)
|
|
and trajectory_len < previous_offer_len + SYNTHESIZED_OFFER_REFRESH_STEP_THRESHOLD
|
|
and (not trajectory_goal_complete or getattr(ctx, "synthesized_block_offered_goal_complete", False))
|
|
and not reopened
|
|
):
|
|
return None
|
|
synthesized = synthesize_code_block(
|
|
trajectory, reached_download_target=getattr(ctx, "reached_download_target", None)
|
|
)
|
|
if synthesized is None:
|
|
return None
|
|
|
|
ctx.synthesized_block_offered = True
|
|
ctx.synthesized_block_offered_trajectory_len = trajectory_len
|
|
ctx.synthesized_block_offered_goal_complete = trajectory_goal_complete
|
|
if reopened_after_failed_run:
|
|
ctx.synthesized_block_reopened_after_failed_run = True
|
|
goal = getattr(ctx, "block_goal_main_goal", "") or getattr(ctx, "user_message", "") or ""
|
|
return {"role": "user", "content": render_synthesized_offer_text(synthesized, trajectory, goal=goal)}
|
|
|
|
|
|
def _completion_verification_unsatisfied(ctx: Any) -> bool:
|
|
result = getattr(ctx, "completion_verification_result", None)
|
|
if result is None or getattr(result, "status", None) != "evaluated":
|
|
return False
|
|
is_fully_satisfied = getattr(result, "is_fully_satisfied", None)
|
|
if callable(is_fully_satisfied) and is_fully_satisfied():
|
|
return False
|
|
return True
|
|
|
|
|
|
def _last_scout_interaction_commits(trajectory: list[Any]) -> bool:
|
|
if not trajectory:
|
|
return False
|
|
last = trajectory[-1]
|
|
if not isinstance(last, dict):
|
|
return False
|
|
return str(last.get("tool_name") or "") in _SYNTHESIZED_BLOCK_COMMIT_TOOLS and not is_generic_entry_opener_click(
|
|
last
|
|
)
|
|
|
|
|
|
def synthesized_persistence_reopened_after_failed_run(ctx: Any) -> bool:
|
|
if getattr(ctx, "synthesized_block_reopened_after_failed_run", False):
|
|
return True
|
|
if not getattr(ctx, "update_workflow_called", False):
|
|
return False
|
|
if not getattr(ctx, "test_after_update_done", False):
|
|
return False
|
|
if getattr(ctx, "last_test_ok", None) is not False:
|
|
return False
|
|
if getattr(ctx, "last_test_non_retriable_nav_error", None):
|
|
return False
|
|
if not _completion_verification_unsatisfied(ctx):
|
|
return False
|
|
trajectory = getattr(ctx, "scout_trajectory", None)
|
|
if not isinstance(trajectory, list):
|
|
return False
|
|
previous_offer_len = getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0
|
|
if len(trajectory) <= previous_offer_len:
|
|
return False
|
|
return _last_scout_interaction_commits(trajectory)
|
|
|
|
|
|
def synthesized_persistence_reopened(ctx: AgentContext) -> bool:
|
|
if ctx.synthesized_block_reopened_for_output_coverage:
|
|
return True
|
|
return synthesized_persistence_reopened_after_failed_run(ctx)
|
|
|
|
|
|
_COVERAGE_GENERIC_TOKENS = frozenset(
|
|
{
|
|
"output",
|
|
"value",
|
|
"values",
|
|
"data",
|
|
"result",
|
|
"results",
|
|
"record",
|
|
"records",
|
|
"detail",
|
|
"details",
|
|
"info",
|
|
"information",
|
|
"field",
|
|
"fields",
|
|
"the",
|
|
"of",
|
|
}
|
|
)
|
|
|
|
|
|
def _canonical_output_path(path: str) -> str:
|
|
return path if path.startswith("output.") else requested_output_path_for_field(path)
|
|
|
|
|
|
def _active_completion_criteria(ctx: AgentContext) -> tuple[CompletionCriterion, ...]:
|
|
turn_state = ctx.completion_criteria_turn_state
|
|
if turn_state is None or turn_state.decision is None:
|
|
return ()
|
|
return turn_state.decision.criteria
|
|
|
|
|
|
def _pre_run_gated_completion_criteria(ctx: AgentContext) -> tuple[CompletionCriterion, ...]:
|
|
"""Completion criteria whose requested output is observable before a run. A criterion whose
|
|
evidence lives in a registered output parameter or artifact content is only confirmable
|
|
post-run, so gating the scout window on it would demand an unsatisfiable pre-run observation.
|
|
The persist scaffold still demands those paths at author time — that gate is SKY-11591's."""
|
|
return tuple(
|
|
criterion
|
|
for criterion in _active_completion_criteria(ctx)
|
|
if criterion.requested_output_evidence_source not in _PRE_RUN_UNGATED_EVIDENCE_SOURCES
|
|
)
|
|
|
|
|
|
def _requested_output_paths_for_ctx(ctx: AgentContext) -> set[str]:
|
|
paths = set(requested_output_paths(_pre_run_gated_completion_criteria(ctx)))
|
|
repair_context = ctx.last_code_authoring_repair_context
|
|
if repair_context is not None:
|
|
paths.update(
|
|
_canonical_output_path(raw)
|
|
for raw in repair_context.required_goal_value_paths
|
|
if isinstance(raw, str) and raw
|
|
)
|
|
return paths
|
|
|
|
|
|
def _requested_output_coverage_tokens(ctx: AgentContext) -> dict[str, frozenset[str]]:
|
|
aliases = schema_output_path_aliases_from_criteria(list(_pre_run_gated_completion_criteria(ctx)))
|
|
tokens_by_path: dict[str, set[str]] = {}
|
|
for alias_key, path in aliases.items():
|
|
tokens_by_path.setdefault(path, set()).update(COVERAGE_TOKEN_RE.findall(alias_key.lower()))
|
|
for path in _requested_output_paths_for_ctx(ctx):
|
|
leaf_tokens = COVERAGE_TOKEN_RE.findall(path.removeprefix("output.").lower())
|
|
tokens_by_path.setdefault(path, set()).update(
|
|
token for token in leaf_tokens if token not in _COVERAGE_GENERIC_TOKENS
|
|
)
|
|
return {
|
|
path: frozenset(token for token in tokens if not token.isdigit()) for path, tokens in tokens_by_path.items()
|
|
}
|
|
|
|
|
|
def uncovered_requested_output_paths(ctx: AgentContext) -> set[str]:
|
|
"""Requested-output paths not yet credited by scouted evidence. A path whose identifying
|
|
tokens are all generic (e.g. ``output.data``) is uncoverable by token match and is exempted,
|
|
so it falls through to the shape heuristic instead of pinning the gate open forever."""
|
|
requested = _requested_output_paths_for_ctx(ctx)
|
|
if not requested:
|
|
return set()
|
|
tokens_by_path = _requested_output_coverage_tokens(ctx)
|
|
covered: set[str] = set(ctx.scouted_output_covered_paths)
|
|
download = ctx.reached_download_target
|
|
if download is not None and download.selector:
|
|
covered |= REGISTERED_DOWNLOAD_REQUESTED_OUTPUT_PATHS
|
|
return {path for path in requested if path not in covered and tokens_by_path.get(path)}
|
|
|
|
|
|
def record_scouted_output_coverage(ctx: AgentContext, page_evidence: dict[str, Any]) -> None:
|
|
coverage_tokens = _requested_output_coverage_tokens(ctx)
|
|
if not coverage_tokens:
|
|
return
|
|
newly_covered = covered_output_paths_in_result_containers(page_evidence.get("result_containers"), coverage_tokens)
|
|
if not newly_covered:
|
|
return
|
|
ctx.scouted_output_covered_paths.update(newly_covered)
|
|
LOG.info(
|
|
"copilot_scouted_output_coverage_credited",
|
|
newly_covered_paths=sorted(newly_covered),
|
|
source_url=page_evidence.get("current_url") or "",
|
|
)
|
|
|
|
|
|
def synthesized_trajectory_is_goal_complete(ctx: Any) -> bool:
|
|
"""Complete once the scout trajectory covers a durable entry followed by a commit (or a reached download target)
|
|
with no requested-output path left uncovered; an empty requested-output set falls through to the shape heuristic
|
|
byte-identically, so an entry ``synthesize_code_block`` would drop never counts as complete."""
|
|
if uncovered_requested_output_paths(ctx):
|
|
return False
|
|
trajectory = getattr(ctx, "scout_trajectory", None)
|
|
if not isinstance(trajectory, list) or not trajectory:
|
|
return False
|
|
download = getattr(ctx, "reached_download_target", None)
|
|
if isinstance(download, ReachedDownloadTarget) and download.selector:
|
|
return True
|
|
last_entry_index: int | None = None
|
|
for index, item in enumerate(trajectory):
|
|
if isinstance(item, dict) and is_durable_fallback_entry_target(item):
|
|
last_entry_index = index
|
|
if last_entry_index is None:
|
|
return False
|
|
return any(
|
|
isinstance(item, dict)
|
|
and str(item.get("tool_name") or "") in _SYNTHESIZED_BLOCK_COMMIT_TOOLS
|
|
and not is_generic_entry_opener_click(item)
|
|
for item in trajectory[last_entry_index + 1 :]
|
|
)
|
|
|
|
|
|
def _has_unconsumed_output_contract_advisory_grant(ctx: Any) -> bool:
|
|
states = getattr(ctx, "output_contract_actuation_by_signature", None)
|
|
if not isinstance(states, dict):
|
|
return False
|
|
return any(state == OutputContractAdvisoryState.GRANTED for state in states.values())
|
|
|
|
|
|
def _should_force_advisory_run_dispatch(ctx: Any) -> bool:
|
|
"""Actuate a granted output-contract advisory run through the same tool_choice forcing lane as the
|
|
synthesized-persistence force, rather than leaving dispatch to the model. Fires only while a grant is
|
|
unconsumed, authority permits running blocks, and no genuinely-terminal blocker holds."""
|
|
if not _has_unconsumed_output_contract_advisory_grant(ctx):
|
|
return False
|
|
if not _turn_intent_can_update_and_run_without_user_input(getattr(ctx, "turn_intent", None)):
|
|
return False
|
|
if normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)) != (
|
|
BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
):
|
|
return False
|
|
if getattr(ctx, "turn_halt", None) is not None:
|
|
return False
|
|
return not blocker_signal_is_genuinely_terminal(getattr(ctx, "blocker_signal", None))
|
|
|
|
|
|
def _should_force_synthesized_block_persistence(ctx: Any) -> bool:
|
|
if getattr(ctx, "update_workflow_called", False) and not synthesized_persistence_reopened(ctx):
|
|
return False
|
|
if not _turn_intent_can_update_and_run_without_user_input(getattr(ctx, "turn_intent", None)):
|
|
return False
|
|
if normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)) != (
|
|
BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
):
|
|
return False
|
|
if not getattr(ctx, "synthesized_block_offered", False):
|
|
return False
|
|
trajectory = getattr(ctx, "scout_trajectory", None) or []
|
|
if (getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0) != len(trajectory):
|
|
return False
|
|
if not getattr(ctx, "synthesized_block_offered_goal_complete", False):
|
|
return False
|
|
return synthesized_trajectory_is_goal_complete(ctx)
|
|
|
|
|
|
def _should_block_mutating_tool_after_synthesized_offer(ctx: Any, tool_name: str) -> bool:
|
|
if tool_name not in _SYNTHESIZED_BLOCK_PERSISTENCE_MUTATING_TOOLS:
|
|
return False
|
|
if uncovered_requested_output_paths(ctx):
|
|
return False
|
|
if getattr(ctx, "update_workflow_called", False) and not synthesized_persistence_reopened(ctx):
|
|
return False
|
|
if not _turn_intent_can_update_and_run_without_user_input(getattr(ctx, "turn_intent", None)):
|
|
return False
|
|
if normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)) != (
|
|
BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
):
|
|
return False
|
|
if not getattr(ctx, "synthesized_block_offered", False):
|
|
return False
|
|
trajectory = getattr(ctx, "scout_trajectory", None) or []
|
|
return (getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0) == len(trajectory)
|
|
|
|
|
|
def _ambiguous_bare_selector_repair_context(ctx: Any) -> Any | None:
|
|
repair_context = getattr(ctx, "last_code_authoring_repair_context", None)
|
|
if getattr(repair_context, "reason_code", None) != "ambiguous_bare_selector":
|
|
return None
|
|
if getattr(repair_context, "workflow_run_id", None):
|
|
return None
|
|
if getattr(ctx, "last_run_blocks_workflow_run_id", None):
|
|
return None
|
|
return repair_context
|
|
|
|
|
|
def _ambiguous_bare_selector_rescout_key(ctx: Any) -> str | None:
|
|
repair_context = _ambiguous_bare_selector_repair_context(ctx)
|
|
if repair_context is None:
|
|
return None
|
|
if getattr(repair_context, "refiner_selector", None):
|
|
return None
|
|
selector_alternatives = getattr(repair_context, "selector_alternatives", None)
|
|
if isinstance(selector_alternatives, list) and selector_alternatives:
|
|
return None
|
|
payload = {
|
|
"block_label": str(getattr(repair_context, "block_label", "") or ""),
|
|
"selector": str(getattr(repair_context, "selector", "") or ""),
|
|
"source_url": str(getattr(repair_context, "source_url", "") or ""),
|
|
}
|
|
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def _ambiguous_bare_selector_rescout_signal_state(ctx: Any, tool_name: str) -> str | None:
|
|
if tool_name != "evaluate":
|
|
return None
|
|
repair_context = _ambiguous_bare_selector_repair_context(ctx)
|
|
if repair_context is None:
|
|
return None
|
|
if getattr(repair_context, "refiner_selector", None):
|
|
return "block"
|
|
selector_alternatives = getattr(repair_context, "selector_alternatives", None)
|
|
if isinstance(selector_alternatives, list) and selector_alternatives:
|
|
return "block"
|
|
key = _ambiguous_bare_selector_rescout_key(ctx)
|
|
if key is None:
|
|
return None
|
|
if getattr(ctx, "ambiguous_bare_selector_rescout_context_key", None) == key:
|
|
return "block"
|
|
# Track the one allowed same-page rescout for this author-time repair context.
|
|
ctx.ambiguous_bare_selector_rescout_context_key = key
|
|
return "allow"
|
|
|
|
|
|
def _uncovered_output_reject_rescout_key(canonical_paths: set[str], structural_failure_identity: str) -> str:
|
|
return f"{structural_failure_identity}|{','.join(sorted(canonical_paths))}"
|
|
|
|
|
|
def _active_uncovered_output_reject_paths(ctx: AgentContext) -> set[str]:
|
|
canonical = {
|
|
_canonical_output_path(path)
|
|
for path in author_time_reject_missing_output_paths(ctx.latest_recorded_build_test_outcome)
|
|
}
|
|
return canonical & uncovered_requested_output_paths(ctx) if canonical else set()
|
|
|
|
|
|
def _uncovered_output_reject_admits_evaluate(ctx: CopilotContext, tool_name: str) -> bool:
|
|
return tool_name == "evaluate" and bool(_active_uncovered_output_reject_paths(ctx))
|
|
|
|
|
|
def _actuation_obligation_live_fill_delivery_required(ctx: CopilotContext) -> bool:
|
|
turn_intent = getattr(ctx, "turn_intent", None)
|
|
if (
|
|
not isinstance(turn_intent, TurnIntent)
|
|
or turn_intent.mode != TurnIntentMode.BUILD
|
|
or RequiredContextKey.BROWSER_STATE not in turn_intent.required_context
|
|
):
|
|
return False
|
|
if normalize_block_authoring_policy(ctx.block_authoring_policy) != BlockAuthoringPolicy.CODE_ONLY_BROWSER:
|
|
return False
|
|
request_policy = getattr(ctx, "request_policy", None)
|
|
criteria: list[CompletionCriterion] = []
|
|
if isinstance(request_policy, RequestPolicy):
|
|
criteria.extend(request_policy.completion_criteria)
|
|
turn_state = getattr(ctx, "completion_criteria_turn_state", None)
|
|
if turn_state is not None and turn_state.decision is not None:
|
|
criteria.extend(turn_state.decision.criteria)
|
|
if any(completion_criterion_requires_browser_fill_delivery(criterion) for criterion in criteria):
|
|
return True
|
|
trajectory = getattr(ctx, "scout_trajectory", None)
|
|
return trajectory_has_browser_fill_interaction(trajectory) if isinstance(trajectory, list) else False
|
|
|
|
|
|
def _actuation_obligation_required_fill_tool(ctx: CopilotContext) -> str | None:
|
|
if _actuation_obligation_live_fill_delivery_required(ctx):
|
|
return _ACTUATION_OBLIGATION_REQUIRED_FILL_TOOL
|
|
return None
|
|
|
|
|
|
def _actuation_obligation_admits_required_fill_tool(ctx: CopilotContext, tool_name: str) -> bool:
|
|
return tool_name == _actuation_obligation_required_fill_tool(ctx)
|
|
|
|
|
|
def consume_uncovered_output_reopen_event(ctx: CopilotContext) -> bool:
|
|
"""Arm a one-shot scout-window reopen for the first author-time reject citing an uncovered
|
|
requested-output path. Returns True only on that first reject per structural identity; a
|
|
repeat identical reject falls through so it counts normally toward the repair ceiling."""
|
|
active = _active_uncovered_output_reject_paths(ctx)
|
|
if not active:
|
|
return False
|
|
latest = ctx.latest_recorded_build_test_outcome
|
|
if latest is None:
|
|
return False
|
|
key = _uncovered_output_reject_rescout_key(active, latest.structural_failure_identity)
|
|
if ctx.uncovered_output_rescout_context_key == key:
|
|
return False
|
|
ctx.uncovered_output_rescout_context_key = key
|
|
ctx.synthesized_block_reopened_for_output_coverage = True
|
|
return True
|
|
|
|
|
|
def uncovered_output_reject_scout_steer_signal(ctx: AgentContext, tool_name: str) -> CopilotToolBlockerSignal | None:
|
|
if tool_name not in _SYNTHESIZED_BLOCK_REAUTHORING_TOOLS:
|
|
return None
|
|
if not ctx.synthesized_block_reopened_for_output_coverage:
|
|
return None
|
|
active = _active_uncovered_output_reject_paths(ctx)
|
|
if not active:
|
|
return None
|
|
latest = ctx.latest_recorded_build_test_outcome
|
|
if latest is None:
|
|
return None
|
|
key = _uncovered_output_reject_rescout_key(active, latest.structural_failure_identity)
|
|
if ctx.uncovered_output_rescout_steer_key == key:
|
|
return None
|
|
payload: AuthorTimeGateAblationPayload = {
|
|
"uncovered_output_paths": sorted(active),
|
|
"structural_failure_identity": latest.structural_failure_identity,
|
|
}
|
|
if record_author_time_gate_ablation_event(
|
|
ctx,
|
|
gate_id="uncovered_output_rescout_steer",
|
|
reason_code=UNCOVERED_OUTPUT_RESCOUT_STEER_REASON_CODE,
|
|
fingerprint=key,
|
|
blocked_tool=tool_name,
|
|
payload=payload,
|
|
):
|
|
return None
|
|
ctx.uncovered_output_rescout_steer_key = key
|
|
named_paths = ", ".join(sorted(active))
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text=(
|
|
"The authored workflow still leaves these requested output paths unobserved: "
|
|
f"{named_paths}. Do NOT re-author yet. Call evaluate to scout the page where those values "
|
|
"appear until they are observed, then author a block that returns them."
|
|
),
|
|
user_facing_reason="I need to view the page with the requested details before saving the workflow.",
|
|
recovery_hint="retry_with_different_tool",
|
|
cleared_by_tools=frozenset({"evaluate"}),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=False,
|
|
internal_reason_code=UNCOVERED_OUTPUT_RESCOUT_STEER_REASON_CODE,
|
|
blocked_tool=tool_name,
|
|
extra={"uncovered_output_paths": sorted(active)},
|
|
)
|
|
|
|
|
|
def _should_block_tool_after_unresolved_recorded_outcome(ctx: Any, tool_name: str) -> bool:
|
|
if tool_name not in _SYNTHESIZED_BLOCK_PERSISTENCE_MUTATING_TOOLS and tool_name != "update_workflow":
|
|
return False
|
|
if not _turn_intent_can_update_and_run_without_user_input(getattr(ctx, "turn_intent", None)):
|
|
return False
|
|
if normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)) != (
|
|
BlockAuthoringPolicy.CODE_ONLY_BROWSER
|
|
):
|
|
return False
|
|
latest = getattr(ctx, "latest_recorded_build_test_outcome", None)
|
|
if not isinstance(latest, RecordedBuildTestOutcome) or not latest.is_authoritative:
|
|
return False
|
|
if latest.phase != "persisted_block_run" or latest.reason_code != "outcome_not_demonstrated":
|
|
return False
|
|
return _completion_verification_unsatisfied(ctx)
|
|
|
|
|
|
def synthesized_block_persistence_signal(ctx: Any, tool_name: str) -> CopilotToolBlockerSignal | None:
|
|
if _should_block_tool_after_unresolved_recorded_outcome(ctx, tool_name):
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text=(
|
|
"The last recorded test outcome is authoritative and still has unsatisfied completion criteria. "
|
|
f"Call {SYNTHESIZED_BLOCK_PERSISTENCE_TOOL} with a materially changed authored workflow now; "
|
|
"do not spend the repair attempt on another standalone tool call."
|
|
),
|
|
user_facing_reason="I need to revise and test the workflow code instead of interacting with the page.",
|
|
recovery_hint="retry_with_different_tool",
|
|
cleared_by_tools=frozenset({SYNTHESIZED_BLOCK_PERSISTENCE_TOOL}),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=False,
|
|
internal_reason_code=SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE,
|
|
blocked_tool=tool_name,
|
|
extra={"recorded_outcome_reason_code": "outcome_not_demonstrated"},
|
|
)
|
|
if tool_name in _SYNTHESIZED_BLOCK_PERSISTENCE_ALLOWED_TOOLS:
|
|
return None
|
|
ambiguous_selector_rescout_state = _ambiguous_bare_selector_rescout_signal_state(ctx, tool_name)
|
|
if ambiguous_selector_rescout_state == "allow":
|
|
return None
|
|
if _uncovered_output_reject_admits_evaluate(ctx, tool_name):
|
|
return None
|
|
if _actuation_obligation_admits_required_fill_tool(ctx, tool_name):
|
|
return None
|
|
if (
|
|
ambiguous_selector_rescout_state != "block"
|
|
and not _should_force_synthesized_block_persistence(ctx)
|
|
and not _should_block_mutating_tool_after_synthesized_offer(ctx, tool_name)
|
|
):
|
|
return None
|
|
return CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text=(
|
|
"A synthesized code block offer is already available for this authoring turn. "
|
|
f"Call {SYNTHESIZED_BLOCK_PERSISTENCE_TOOL} with that block now before any more scouting, "
|
|
"reading, page evaluation, or browser interaction. This blocker clears only after "
|
|
f"{SYNTHESIZED_BLOCK_PERSISTENCE_TOOL} succeeds."
|
|
),
|
|
user_facing_reason="I need to save and test the drafted workflow before scouting more.",
|
|
recovery_hint="retry_with_different_tool",
|
|
cleared_by_tools=frozenset({SYNTHESIZED_BLOCK_PERSISTENCE_TOOL}),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=False,
|
|
internal_reason_code=SYNTHESIZED_BLOCK_PERSISTENCE_REASON_CODE,
|
|
blocked_tool=tool_name,
|
|
extra={
|
|
"synthesized_block_offered_trajectory_len": (
|
|
getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
def _runner_kwargs_with_forced_tool_choice(runner_kwargs: dict[str, Any], tool_name: str) -> dict[str, Any]:
|
|
run_config = runner_kwargs.get("run_config")
|
|
if isinstance(run_config, RunConfig):
|
|
model_settings = run_config.model_settings
|
|
if isinstance(model_settings, ModelSettings):
|
|
forced_settings = replace(model_settings, tool_choice=tool_name)
|
|
else:
|
|
forced_settings = ModelSettings(tool_choice=tool_name)
|
|
return {**runner_kwargs, "run_config": replace(run_config, model_settings=forced_settings)}
|
|
return {**runner_kwargs, "run_config": RunConfig(model_settings=ModelSettings(tool_choice=tool_name))}
|
|
|
|
|
|
def _assemble_enforcement_messages(
|
|
screenshot_msg: dict[str, Any] | None,
|
|
nudge_content: str | None,
|
|
synthesized_msg: dict[str, Any] | None,
|
|
) -> list[Any]:
|
|
"""Build the extra messages for an enforcement retry, ordered so a nudge, when present, stays last.
|
|
|
|
The screenshot rides as its own user-role message because OpenAI rejects image parts inside a tool message.
|
|
"""
|
|
extra_msgs: list[Any] = []
|
|
if screenshot_msg is not None:
|
|
extra_msgs.append(screenshot_msg)
|
|
if nudge_content is not None:
|
|
extra_msgs.append({"role": "user", "content": NUDGE_SENTINEL + nudge_content})
|
|
if synthesized_msg is not None:
|
|
extra_msgs.insert(0, synthesized_msg)
|
|
return extra_msgs
|
|
|
|
|
|
async def run_with_enforcement(
|
|
agent: Agent,
|
|
initial_input: str | list,
|
|
ctx: Any,
|
|
stream: EventSourceStream,
|
|
**runner_kwargs: Any,
|
|
) -> RunResultStreaming:
|
|
"""Run agent with enforcement nudges, preserving conversation history."""
|
|
session = runner_kwargs.pop("session", None)
|
|
copilot_config = runner_kwargs.pop("copilot_config", None) or CopilotConfig()
|
|
current_input: str | list = initial_input
|
|
start_time = time.monotonic()
|
|
ctx.copilot_run_start_monotonic = start_time
|
|
iteration = 0
|
|
pending_recovery_nudge: str | None = None
|
|
|
|
while True:
|
|
# Client disconnect is no longer treated as a stop signal. The
|
|
# SSE stream silently drops events once the browser is gone, but
|
|
# the agent keeps running so the reply can be persisted to the
|
|
# chat history on the server side (see SKY-8986).
|
|
elapsed = time.monotonic() - start_time
|
|
if elapsed > TOTAL_TIMEOUT_SECONDS:
|
|
_mark_copilot_total_timeout(ctx)
|
|
raise CopilotTotalTimeoutError()
|
|
|
|
if iteration >= MAX_ITERATIONS:
|
|
LOG.error("Enforcement iteration cap reached", max_iterations=MAX_ITERATIONS)
|
|
raise CopilotTotalTimeoutError()
|
|
|
|
# When the current turn contains image payloads, the session-backed
|
|
# input filter cannot protect us — the payload is in current_input,
|
|
# not in session history. Estimate regardless of session.
|
|
if isinstance(current_input, list):
|
|
est = estimate_tokens(current_input)
|
|
LOG.info("Token estimate before model call", tokens=est, iteration=iteration)
|
|
if est > copilot_config.token_budget:
|
|
LOG.warning(
|
|
"Token estimate exceeds budget, aggressively pruning",
|
|
tokens=est,
|
|
budget=copilot_config.token_budget,
|
|
)
|
|
current_input = aggressive_prune(current_input)
|
|
|
|
tracked_stream = _SendTrackingStream(stream)
|
|
with copilot_span(
|
|
"enforcement_iteration",
|
|
data={"iteration": iteration, "elapsed_seconds": round(elapsed, 3)},
|
|
):
|
|
force_synthesized_block_persistence = _should_force_synthesized_block_persistence(ctx)
|
|
force_advisory_run_dispatch = _should_force_advisory_run_dispatch(ctx)
|
|
force_run_dispatch = force_synthesized_block_persistence or force_advisory_run_dispatch
|
|
current_runner_kwargs = (
|
|
_runner_kwargs_with_forced_tool_choice(runner_kwargs, SYNTHESIZED_BLOCK_PERSISTENCE_TOOL)
|
|
if force_run_dispatch
|
|
else runner_kwargs
|
|
)
|
|
effective_run_config = current_runner_kwargs.get("run_config")
|
|
effective_model_settings = (
|
|
effective_run_config.model_settings if isinstance(effective_run_config, RunConfig) else None
|
|
)
|
|
turn_intent = getattr(ctx, "turn_intent", None)
|
|
turn_intent_authority = getattr(turn_intent, "authority", None)
|
|
LOG.info(
|
|
"copilot synthesized persistence force decision",
|
|
force_synthesized_block_persistence=force_synthesized_block_persistence,
|
|
force_advisory_run_dispatch=force_advisory_run_dispatch,
|
|
forced_tool_name=(SYNTHESIZED_BLOCK_PERSISTENCE_TOOL if force_run_dispatch else None),
|
|
chosen_tool_name=(SYNTHESIZED_BLOCK_PERSISTENCE_TOOL if force_run_dispatch else None),
|
|
turn_intent_mode=getattr(getattr(turn_intent, "mode", None), "value", None),
|
|
turn_intent_may_update_workflow=getattr(turn_intent_authority, "may_update_workflow", None),
|
|
turn_intent_may_run_blocks=getattr(turn_intent_authority, "may_run_blocks", None),
|
|
turn_intent_requires_user_input=getattr(turn_intent_authority, "requires_user_input", None),
|
|
block_authoring_policy=getattr(
|
|
normalize_block_authoring_policy(getattr(ctx, "block_authoring_policy", None)),
|
|
"value",
|
|
None,
|
|
),
|
|
synthesized_block_offered=getattr(ctx, "synthesized_block_offered", False),
|
|
synthesized_block_offered_trajectory_len=(
|
|
getattr(ctx, "synthesized_block_offered_trajectory_len", 0) or 0
|
|
),
|
|
update_workflow_called=getattr(ctx, "update_workflow_called", False),
|
|
effective_tool_choice=getattr(effective_model_settings, "tool_choice", None),
|
|
)
|
|
try:
|
|
result = await _run_streamed_with_deadline(
|
|
agent,
|
|
current_input,
|
|
ctx,
|
|
session,
|
|
tracked_stream,
|
|
current_runner_kwargs,
|
|
start_time,
|
|
iteration,
|
|
)
|
|
except asyncio.CancelledError:
|
|
_mark_copilot_total_timeout_if_elapsed(ctx, start_time)
|
|
raise
|
|
except Exception as e:
|
|
if not _is_context_window_error(e):
|
|
raise
|
|
if tracked_stream.emitted:
|
|
# The provider started streaming then aborted; retrying
|
|
# would double-emit frames to the client.
|
|
LOG.error(
|
|
"Context window exceeded after partial emission; not retrying",
|
|
error=str(e),
|
|
iteration=iteration,
|
|
has_session=session is not None,
|
|
)
|
|
raise
|
|
LOG.error(
|
|
"Context window exceeded, retrying with aggressive prune",
|
|
error=str(e),
|
|
iteration=iteration,
|
|
has_session=session is not None,
|
|
)
|
|
try:
|
|
current_input, images_stripped = await _recover_from_context_overflow(session, current_input)
|
|
except asyncio.CancelledError:
|
|
_mark_copilot_total_timeout_if_elapsed(ctx, start_time)
|
|
raise
|
|
if images_stripped:
|
|
# The agent could otherwise reason about the page from
|
|
# memory on the next turn; warn it explicitly.
|
|
pending_recovery_nudge = _nudge(copilot_config, "screenshot_dropped")
|
|
tracked_stream = _SendTrackingStream(stream)
|
|
try:
|
|
result = await _run_streamed_with_deadline(
|
|
agent,
|
|
current_input,
|
|
ctx,
|
|
session,
|
|
tracked_stream,
|
|
current_runner_kwargs,
|
|
start_time,
|
|
iteration,
|
|
)
|
|
except asyncio.CancelledError:
|
|
_mark_copilot_total_timeout_if_elapsed(ctx, start_time)
|
|
raise
|
|
except Exception as retry_err:
|
|
# Never retry twice; even a second overflow surfaces as a
|
|
# real failure rather than spinning.
|
|
LOG.error(
|
|
"Context window recovery retry failed",
|
|
original_error=str(e),
|
|
retry_error=str(retry_err),
|
|
iteration=iteration,
|
|
has_session=session is not None,
|
|
)
|
|
raise
|
|
|
|
# The post-run screenshot drain must follow the enforcement check:
|
|
# without a nudge, re-invoking with just the screenshot would replace
|
|
# the agent's already-final REPLY with one synthesized from a single
|
|
# browser frame.
|
|
if pending_recovery_nudge is not None:
|
|
nudge: str | None = pending_recovery_nudge
|
|
pending_recovery_nudge = None
|
|
else:
|
|
nudge = _check_enforcement(ctx, result, copilot_config)
|
|
|
|
# The offer is independent of the nudge: a clean scout-then-author turn
|
|
# finalizes with nudge=None, so injecting it only inside the nudge branch
|
|
# would never reach the model. Compute it once here so it rides both the
|
|
# nudge path and the finalize path.
|
|
synthesized_msg = _maybe_synthesized_block_offer_msg(ctx)
|
|
|
|
if nudge is None and synthesized_msg is None:
|
|
_consume_pending_screenshots(ctx)
|
|
_maybe_raise_non_retriable_nav(ctx)
|
|
return result
|
|
|
|
if nudge is not None and nudge == _nudge(copilot_config, "post_update"):
|
|
if ctx.post_update_nudge_count >= MAX_POST_UPDATE_NUDGES:
|
|
LOG.warning(
|
|
"Enforcement exhausted post-update nudges, allowing response",
|
|
nudge_count=ctx.post_update_nudge_count,
|
|
)
|
|
_consume_pending_screenshots(ctx)
|
|
_maybe_raise_non_retriable_nav(ctx)
|
|
return result
|
|
ctx.post_update_nudge_count += 1
|
|
|
|
nudge_type = _nudge_type_for_log(nudge, copilot_config) if nudge is not None else "synthesized_block_offer"
|
|
LOG.info("Enforcement nudge", nudge_type=nudge_type, iteration=iteration)
|
|
|
|
# OpenAI rejects images in tool messages, so a queued post-run
|
|
# screenshot rides as its own user message just before the nudge.
|
|
screenshot_msg = _consume_pending_screenshots(ctx)
|
|
if screenshot_msg is not None:
|
|
LOG.info("Injecting screenshot user message", count=len(screenshot_msg["content"]) - 1)
|
|
|
|
with copilot_span("enforcement_nudge", data={"nudge_type": nudge_type, "iteration": iteration}):
|
|
extra_msgs = _assemble_enforcement_messages(screenshot_msg, nudge, synthesized_msg)
|
|
current_input = (
|
|
extra_msgs if session is not None else _prune_input_list(result.to_input_list()) + extra_msgs
|
|
)
|
|
# Signal the narrator that the agent is re-entering the loop after an
|
|
# enforcement correction. stream_to_sse creates the state on the first
|
|
# pass; on later passes we poke the transition latch directly so the
|
|
# next narration (produced after the next tool round-trip) can describe
|
|
# the course-correction.
|
|
narrator_state = getattr(ctx, "narrator_state", None)
|
|
if narrator_state is not None:
|
|
narrator_state.record_transition(TransitionKind.ENFORCEMENT_RETRY)
|
|
iteration += 1
|