diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 000000000..f69b21e6f --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,11 @@ +{ + "ai": "codex", + "ai_commands_dir": null, + "ai_skills": true, + "branch_numbering": "sequential", + "here": true, + "offline": true, + "preset": null, + "script": "sh", + "speckit_version": "0.4.1" +} \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 000000000..a4670ff46 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 000000000..024aba0eb --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths and validate branch +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# If paths-only mode, output paths and exit (support JSON + paths-only combined) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 000000000..e1e9a042a --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory +get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + return + fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current branch, with fallback for non-git repositories +get_current_branch() { + # First check if SPECIFY_FEATURE environment variable is set + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD + return + fi + + # For non-git repos, try to find the latest feature directory + local specs_dir="$repo_root/specs" + + if [[ -d "$specs_dir" ]]; then + local latest_feature="" + local highest=0 + local latest_timestamp="" + + for dir in "$specs_dir"/*; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi + fi + fi + fi + done + + if [[ -n "$latest_feature" ]]; then + echo "$latest_feature" + return + fi + fi + + echo "main" # Final fallback +} + +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) +has_git() { + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} + +get_feature_dir() { echo "$1/specs/$2"; } + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) +find_feature_dir_by_prefix() { + local repo_root="$1" + local branch_name="$2" + local specs_dir="$repo_root/specs" + + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match + echo "$specs_dir/$branch_name" + return + fi + + # Search for directories in specs/ that start with this prefix + local matches=() + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"$prefix"-*; do + if [[ -d "$dir" ]]; then + matches+=("$(basename "$dir")") + fi + done + fi + + # Handle results + if [[ ${#matches[@]} -eq 0 ]]; then + # No match found - return the branch name path (will fail later with clear error) + echo "$specs_dir/$branch_name" + elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! + echo "$specs_dir/${matches[0]}" + else + # Multiple matches - this shouldn't happen with proper naming convention + echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 + echo "Please ensure only one spec directory exists per prefix." >&2 + return 1 + fi +} + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local has_git_repo="false" + + if has_git; then + has_git_repo="true" + fi + + # Use prefix-based lookup to support multiple branches per spec + local feature_dir + if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 000000000..0c675d4de --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Only match sequential prefixes (###-*), skip timestamp dirs + if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$dirname" | grep -o '^[0-9]\{3\}') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + local highest=0 + + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$branches" + fi + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local specs_dir="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + + # Get highest number from ALL branches (not just matching short name) + local highest_branch=$(get_highest_from_branches) + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root using common.sh functions which prioritize .specify over git +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + # Calculate how much we need to trim from suffix + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$HAS_GIT" = true ]; then + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + fi + exit 1 + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." + exit 1 + fi + fi +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" +fi + +# Inform the user how to persist the feature variable in their own shell +printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 000000000..60a0fe6d3 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Check if we're on a proper feature branch (only for git repos) +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if it exists +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true +if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + echo "Copied plan template to $IMPL_PLAN" +else + echo "Warning: Plan template not found" + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" +fi +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" + echo "HAS_GIT: $HAS_GIT" +fi diff --git a/.specify/scripts/bash/update-agent-context.sh b/.specify/scripts/bash/update-agent-context.sh new file mode 100755 index 000000000..02afd1493 --- /dev/null +++ b/.specify/scripts/bash/update-agent-context.sh @@ -0,0 +1,837 @@ +#!/usr/bin/env bash + +# Update agent context files with information from plan.md +# +# This script maintains AI agent context files by parsing feature specifications +# and updating agent-specific configuration files with project information. +# +# MAIN FUNCTIONS: +# 1. Environment Validation +# - Verifies git repository structure and branch information +# - Checks for required plan.md files and templates +# - Validates file permissions and accessibility +# +# 2. Plan Data Extraction +# - Parses plan.md files to extract project metadata +# - Identifies language/version, frameworks, databases, and project types +# - Handles missing or incomplete specification data gracefully +# +# 3. Agent File Management +# - Creates new agent context files from templates when needed +# - Updates existing agent files with new project information +# - Preserves manual additions and custom configurations +# - Supports multiple AI agent formats and directory structures +# +# 4. Content Generation +# - Generates language-specific build/test commands +# - Creates appropriate project directory structures +# - Updates technology stacks and recent changes sections +# - Maintains consistent formatting and timestamps +# +# 5. Multi-Agent Support +# - Handles agent-specific file paths and naming conventions +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic +# - Can update single agents or all existing agent files +# - Creates default Claude file if no agent files exist +# +# Usage: ./update-agent-context.sh [agent_type] +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic +# Leave empty to update all existing agent files + +set -e + +# Enable strict error handling +set -u +set -o pipefail + +#============================================================================== +# Configuration and Global Variables +#============================================================================== + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code +AGENT_TYPE="${1:-}" + +# Agent-specific file paths +CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" +GEMINI_FILE="$REPO_ROOT/GEMINI.md" +COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md" +CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" +QWEN_FILE="$REPO_ROOT/QWEN.md" +AGENTS_FILE="$REPO_ROOT/AGENTS.md" +WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" +JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md" +KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" +AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" +ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" +CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" +QODER_FILE="$REPO_ROOT/QODER.md" +# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid +# updating the same file multiple times. +AMP_FILE="$AGENTS_FILE" +SHAI_FILE="$REPO_ROOT/SHAI.md" +TABNINE_FILE="$REPO_ROOT/TABNINE.md" +KIRO_FILE="$AGENTS_FILE" +AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" +BOB_FILE="$AGENTS_FILE" +VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" +KIMI_FILE="$REPO_ROOT/KIMI.md" +TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" +IFLOW_FILE="$REPO_ROOT/IFLOW.md" + +# Template file +TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" + +# Global variables for parsed plan data +NEW_LANG="" +NEW_FRAMEWORK="" +NEW_DB="" +NEW_PROJECT_TYPE="" + +#============================================================================== +# Utility Functions +#============================================================================== + +log_info() { + echo "INFO: $1" +} + +log_success() { + echo "✓ $1" +} + +log_error() { + echo "ERROR: $1" >&2 +} + +log_warning() { + echo "WARNING: $1" >&2 +} + +# Cleanup function for temporary files +cleanup() { + local exit_code=$? + # Disarm traps to prevent re-entrant loop + trap - EXIT INT TERM + rm -f /tmp/agent_update_*_$$ + rm -f /tmp/manual_additions_$$ + exit $exit_code +} + +# Set up cleanup trap +trap cleanup EXIT INT TERM + +#============================================================================== +# Validation Functions +#============================================================================== + +validate_environment() { + # Check if we have a current branch/feature (git or non-git) + if [[ -z "$CURRENT_BRANCH" ]]; then + log_error "Unable to determine current feature" + if [[ "$HAS_GIT" == "true" ]]; then + log_info "Make sure you're on a feature branch" + else + log_info "Set SPECIFY_FEATURE environment variable or create a feature first" + fi + exit 1 + fi + + # Check if plan.md exists + if [[ ! -f "$NEW_PLAN" ]]; then + log_error "No plan.md found at $NEW_PLAN" + log_info "Make sure you're working on a feature with a corresponding spec directory" + if [[ "$HAS_GIT" != "true" ]]; then + log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" + fi + exit 1 + fi + + # Check if template exists (needed for new files) + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_warning "Template file not found at $TEMPLATE_FILE" + log_warning "Creating new agent files will fail" + fi +} + +#============================================================================== +# Plan Parsing Functions +#============================================================================== + +extract_plan_field() { + local field_pattern="$1" + local plan_file="$2" + + grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ + head -1 | \ + sed "s|^\*\*${field_pattern}\*\*: ||" | \ + sed 's/^[ \t]*//;s/[ \t]*$//' | \ + grep -v "NEEDS CLARIFICATION" | \ + grep -v "^N/A$" || echo "" +} + +parse_plan_data() { + local plan_file="$1" + + if [[ ! -f "$plan_file" ]]; then + log_error "Plan file not found: $plan_file" + return 1 + fi + + if [[ ! -r "$plan_file" ]]; then + log_error "Plan file is not readable: $plan_file" + return 1 + fi + + log_info "Parsing plan data from $plan_file" + + NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") + NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") + NEW_DB=$(extract_plan_field "Storage" "$plan_file") + NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") + + # Log what we found + if [[ -n "$NEW_LANG" ]]; then + log_info "Found language: $NEW_LANG" + else + log_warning "No language information found in plan" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + log_info "Found framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + log_info "Found database: $NEW_DB" + fi + + if [[ -n "$NEW_PROJECT_TYPE" ]]; then + log_info "Found project type: $NEW_PROJECT_TYPE" + fi +} + +format_technology_stack() { + local lang="$1" + local framework="$2" + local parts=() + + # Add non-empty parts + [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") + [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") + + # Join with proper formatting + if [[ ${#parts[@]} -eq 0 ]]; then + echo "" + elif [[ ${#parts[@]} -eq 1 ]]; then + echo "${parts[0]}" + else + # Join multiple parts with " + " + local result="${parts[0]}" + for ((i=1; i<${#parts[@]}; i++)); do + result="$result + ${parts[i]}" + done + echo "$result" + fi +} + +#============================================================================== +# Template and Content Generation Functions +#============================================================================== + +get_project_structure() { + local project_type="$1" + + if [[ "$project_type" == *"web"* ]]; then + echo "backend/\\nfrontend/\\ntests/" + else + echo "src/\\ntests/" + fi +} + +get_commands_for_language() { + local lang="$1" + + case "$lang" in + *"Python"*) + echo "cd src && pytest && ruff check ." + ;; + *"Rust"*) + echo "cargo test && cargo clippy" + ;; + *"JavaScript"*|*"TypeScript"*) + echo "npm test \\&\\& npm run lint" + ;; + *) + echo "# Add commands for $lang" + ;; + esac +} + +get_language_conventions() { + local lang="$1" + echo "$lang: Follow standard conventions" +} + +create_new_agent_file() { + local target_file="$1" + local temp_file="$2" + local project_name="$3" + local current_date="$4" + + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_error "Template not found at $TEMPLATE_FILE" + return 1 + fi + + if [[ ! -r "$TEMPLATE_FILE" ]]; then + log_error "Template file is not readable: $TEMPLATE_FILE" + return 1 + fi + + log_info "Creating new agent context file from template..." + + if ! cp "$TEMPLATE_FILE" "$temp_file"; then + log_error "Failed to copy template file" + return 1 + fi + + # Replace template placeholders + local project_structure + project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + + local commands + commands=$(get_commands_for_language "$NEW_LANG") + + local language_conventions + language_conventions=$(get_language_conventions "$NEW_LANG") + + # Perform substitutions with error checking using safer approach + # Escape special characters for sed by using a different delimiter or escaping + local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') + + # Build technology stack and recent change strings conditionally + local tech_stack + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" + elif [[ -n "$escaped_lang" ]]; then + tech_stack="- $escaped_lang ($escaped_branch)" + elif [[ -n "$escaped_framework" ]]; then + tech_stack="- $escaped_framework ($escaped_branch)" + else + tech_stack="- ($escaped_branch)" + fi + + local recent_change + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" + elif [[ -n "$escaped_lang" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang" + elif [[ -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_framework" + else + recent_change="- $escaped_branch: Added" + fi + + local substitutions=( + "s|\[PROJECT NAME\]|$project_name|" + "s|\[DATE\]|$current_date|" + "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" + "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" + "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" + "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" + "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" + ) + + for substitution in "${substitutions[@]}"; do + if ! sed -i.bak -e "$substitution" "$temp_file"; then + log_error "Failed to perform substitution: $substitution" + rm -f "$temp_file" "$temp_file.bak" + return 1 + fi + done + + # Convert \n sequences to actual newlines + newline=$(printf '\n') + sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" + + # Clean up backup files + rm -f "$temp_file.bak" "$temp_file.bak2" + + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if [[ "$target_file" == *.mdc ]]; then + local frontmatter_file + frontmatter_file=$(mktemp) || return 1 + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + + return 0 +} + + + + +update_existing_agent_file() { + local target_file="$1" + local current_date="$2" + + log_info "Updating existing agent context file..." + + # Use a single temporary file for atomic update + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + # Process the file in one pass + local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") + local new_tech_entries=() + local new_change_entry="" + + # Prepare new technology entries + if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then + new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then + new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") + fi + + # Prepare new change entry + if [[ -n "$tech_stack" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" + elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" + fi + + # Check if sections exist in the file + local has_active_technologies=0 + local has_recent_changes=0 + + if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then + has_active_technologies=1 + fi + + if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then + has_recent_changes=1 + fi + + # Process file line by line + local in_tech_section=false + local in_changes_section=false + local tech_entries_added=false + local changes_entries_added=false + local existing_changes_count=0 + local file_ended=false + + while IFS= read -r line || [[ -n "$line" ]]; do + # Handle Active Technologies section + if [[ "$line" == "## Active Technologies" ]]; then + echo "$line" >> "$temp_file" + in_tech_section=true + continue + elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + # Add new tech entries before closing the section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + in_tech_section=false + continue + elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then + # Add new tech entries before empty line in tech section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + continue + fi + + # Handle Recent Changes section + if [[ "$line" == "## Recent Changes" ]]; then + echo "$line" >> "$temp_file" + # Add new change entry right after the heading + if [[ -n "$new_change_entry" ]]; then + echo "$new_change_entry" >> "$temp_file" + fi + in_changes_section=true + changes_entries_added=true + continue + elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + echo "$line" >> "$temp_file" + in_changes_section=false + continue + elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then + # Keep only first 2 existing changes + if [[ $existing_changes_count -lt 2 ]]; then + echo "$line" >> "$temp_file" + ((existing_changes_count++)) + fi + continue + fi + + # Update timestamp + if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" + else + echo "$line" >> "$temp_file" + fi + done < "$target_file" + + # Post-loop check: if we're still in the Active Technologies section and haven't added new entries + if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + # If sections don't exist, add them at the end of the file + if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + echo "" >> "$temp_file" + echo "## Active Technologies" >> "$temp_file" + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then + echo "" >> "$temp_file" + echo "## Recent Changes" >> "$temp_file" + echo "$new_change_entry" >> "$temp_file" + changes_entries_added=true + fi + + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if [[ "$target_file" == *.mdc ]]; then + if ! head -1 "$temp_file" | grep -q '^---'; then + local frontmatter_file + frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + fi + + # Move temp file to target atomically + if ! mv "$temp_file" "$target_file"; then + log_error "Failed to update target file" + rm -f "$temp_file" + return 1 + fi + + return 0 +} +#============================================================================== +# Main Agent File Update Function +#============================================================================== + +update_agent_file() { + local target_file="$1" + local agent_name="$2" + + if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then + log_error "update_agent_file requires target_file and agent_name parameters" + return 1 + fi + + log_info "Updating $agent_name context file: $target_file" + + local project_name + project_name=$(basename "$REPO_ROOT") + local current_date + current_date=$(date +%Y-%m-%d) + + # Create directory if it doesn't exist + local target_dir + target_dir=$(dirname "$target_file") + if [[ ! -d "$target_dir" ]]; then + if ! mkdir -p "$target_dir"; then + log_error "Failed to create directory: $target_dir" + return 1 + fi + fi + + if [[ ! -f "$target_file" ]]; then + # Create new file from template + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then + if mv "$temp_file" "$target_file"; then + log_success "Created new $agent_name context file" + else + log_error "Failed to move temporary file to $target_file" + rm -f "$temp_file" + return 1 + fi + else + log_error "Failed to create new agent file" + rm -f "$temp_file" + return 1 + fi + else + # Update existing file + if [[ ! -r "$target_file" ]]; then + log_error "Cannot read existing file: $target_file" + return 1 + fi + + if [[ ! -w "$target_file" ]]; then + log_error "Cannot write to existing file: $target_file" + return 1 + fi + + if update_existing_agent_file "$target_file" "$current_date"; then + log_success "Updated existing $agent_name context file" + else + log_error "Failed to update existing agent file" + return 1 + fi + fi + + return 0 +} + +#============================================================================== +# Agent Selection and Processing +#============================================================================== + +update_specific_agent() { + local agent_type="$1" + + case "$agent_type" in + claude) + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 + ;; + gemini) + update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 + ;; + copilot) + update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 + ;; + cursor-agent) + update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 + ;; + qwen) + update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 + ;; + opencode) + update_agent_file "$AGENTS_FILE" "opencode" || return 1 + ;; + codex) + update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 + ;; + windsurf) + update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 + ;; + junie) + update_agent_file "$JUNIE_FILE" "Junie" || return 1 + ;; + kilocode) + update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 + ;; + auggie) + update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 + ;; + roo) + update_agent_file "$ROO_FILE" "Roo Code" || return 1 + ;; + codebuddy) + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 + ;; + qodercli) + update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 + ;; + amp) + update_agent_file "$AMP_FILE" "Amp" || return 1 + ;; + shai) + update_agent_file "$SHAI_FILE" "SHAI" || return 1 + ;; + tabnine) + update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 + ;; + kiro-cli) + update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 + ;; + agy) + update_agent_file "$AGY_FILE" "Antigravity" || return 1 + ;; + bob) + update_agent_file "$BOB_FILE" "IBM Bob" || return 1 + ;; + vibe) + update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 + ;; + kimi) + update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 + ;; + trae) + update_agent_file "$TRAE_FILE" "Trae" || return 1 + ;; + pi) + update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 + ;; + iflow) + update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 + ;; + generic) + log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." + ;; + *) + log_error "Unknown agent type '$agent_type'" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic" + exit 1 + ;; + esac +} + +# Helper: skip non-existent files and files already updated (dedup by +# realpath so that variables pointing to the same file — e.g. AMP_FILE, +# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). +# Uses a linear array instead of associative array for bash 3.2 compatibility. +# Note: defined at top level because bash 3.2 does not support true +# nested/local functions. _updated_paths, _found_agent, and _all_ok are +# initialised exclusively inside update_all_existing_agents so that +# sourcing this script has no side effects on the caller's environment. + +_update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + # Record the file as seen before attempting the update so that: + # (a) aliases pointing to the same path are not retried on failure + # (b) _found_agent reflects file existence, not update success + _updated_paths+=("$real_path") + _found_agent=true + update_agent_file "$file" "$name" +} + +update_all_existing_agents() { + _found_agent=false + _updated_paths=() + local _all_ok=true + + _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false + _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false + _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false + _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false + _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false + _update_if_new "$AMP_FILE" "Amp" || _all_ok=false + _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false + _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false + _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false + _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false + _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false + _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false + _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false + _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false + _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false + _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false + _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false + _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false + _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false + _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false + _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false + _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false + + # If no agent files exist, create a default Claude file + if [[ "$_found_agent" == false ]]; then + log_info "No existing agent files found, creating default Claude file..." + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 + fi + + [[ "$_all_ok" == true ]] +} +print_summary() { + echo + log_info "Summary of changes:" + + if [[ -n "$NEW_LANG" ]]; then + echo " - Added language: $NEW_LANG" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + echo " - Added framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + echo " - Added database: $NEW_DB" + fi + + echo + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]" +} + +#============================================================================== +# Main Execution +#============================================================================== + +main() { + # Validate environment before proceeding + validate_environment + + log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" + + # Parse the plan file to extract project information + if ! parse_plan_data "$NEW_PLAN"; then + log_error "Failed to parse plan data" + exit 1 + fi + + # Process based on agent type argument + local success=true + + if [[ -z "$AGENT_TYPE" ]]; then + # No specific agent provided - update all existing agent files + log_info "No agent specified, updating all existing agent files..." + if ! update_all_existing_agents; then + success=false + fi + else + # Specific agent provided - update only that agent + log_info "Updating specific agent: $AGENT_TYPE" + if ! update_specific_agent "$AGENT_TYPE"; then + success=false + fi + fi + + # Print summary + print_summary + + if [[ "$success" == true ]]; then + log_success "Agent context update completed successfully" + exit 0 + else + log_error "Agent context update completed with errors" + exit 1 + fi +} + +# Execute main function if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.specify/templates/agent-file-template.md b/.specify/templates/agent-file-template.md new file mode 100644 index 000000000..4cc7fd667 --- /dev/null +++ b/.specify/templates/agent-file-template.md @@ -0,0 +1,28 @@ +# [PROJECT NAME] Development Guidelines + +Auto-generated from all feature plans. Last updated: [DATE] + +## Active Technologies + +[EXTRACTED FROM ALL PLAN.MD FILES] + +## Project Structure + +```text +[ACTUAL STRUCTURE FROM PLANS] +``` + +## Commands + +[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] + +## Code Style + +[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] + +## Recent Changes + +[LAST 3 FEATURES AND WHAT THEY ADDED] + + + diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 000000000..0caeacf8b --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 000000000..a4670ff46 --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 000000000..b539a5b12 --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,104 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 000000000..f52815a83 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,128 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 000000000..8accc1d72 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,251 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/AGENTS.md b/AGENTS.md index a0611a713..ddbd63456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,3 +70,10 @@ For non-trivial features or architecture refactoring, follow this workflow: - **[`frontend/AGENTS.md`](file:///Users/alex/Documents/Code/dataelem/Clawith/frontend/AGENTS.md)**: Frontend-specific coding standards, React/TS guidelines, HTTP wrapper usage. > **RULE**: Sub-directory `AGENTS.md` files extend root guidelines. Never duplicate root rules in sub-files. If a rule spans multiple components, put it here. + +## Active Technologies +- Python 3.11+ + FastAPI, SQLAlchemy 2.x async ORM, PostgreSQL, LangGraph checkpoint, Pydantic, httpx (002-tool-runtime-contract) +- PostgreSQL `agent_tool_executions` + LangGraph PostgreSQL checkpoint;不新增第二套 Run 生命周期状态机 (002-tool-runtime-contract) + +## Recent Changes +- 002-tool-runtime-contract: Added Python 3.11+ + FastAPI, SQLAlchemy 2.x async ORM, PostgreSQL, LangGraph checkpoint, Pydantic, httpx diff --git a/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py b/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py index e2f2b48fe..3dc454773 100644 --- a/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py +++ b/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py @@ -10,7 +10,8 @@ Add composite unique constraint uq_enterprise_info_tenant_type on (tenant_id, info_type). Idempotence: - Safe for retry. Pure DDL migration without blocking data locks. + Safe for retry and for fresh installs whose initial metadata already contains + the target tenant-scoped shape. Revision ID: f061_enterprise_info_tenant_id Revises: f060_tenant_id_backfill @@ -18,33 +19,88 @@ """ -from typing import Sequence, Union +from __future__ import annotations + +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. revision: str = "f061_enterprise_info_tenant_id" -down_revision: Union[str, None] = "f060_tenant_id_backfill" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = "f060_tenant_id_backfill" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None +TABLE_NAME = "enterprise_info" +TENANT_COLUMN = "tenant_id" +TENANT_INDEX = "ix_enterprise_info_tenant_id" +LEGACY_UNIQUE = "enterprise_info_info_type_key" +TENANT_UNIQUE = "uq_enterprise_info_tenant_type" -def upgrade() -> None: - # 1. Add tenant_id column with default uuid generator or nullable first if populated - op.add_column("enterprise_info", sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True)) - op.create_index(op.f("ix_enterprise_info_tenant_id"), "enterprise_info", ["tenant_id"], unique=False) - # 2. Drop legacy single info_type unique constraint - op.drop_constraint("enterprise_info_info_type_key", "enterprise_info", type_="unique") +def _schema_names( + *, + offline_default: tuple[set[str], set[str], set[str]], +) -> tuple[set[str], set[str], set[str]]: + """Return column, index, and unique-constraint names for the target table.""" + try: + inspector = sa.inspect(op.get_bind()) + except sa.exc.NoInspectionAvailable: + return offline_default - # 3. Create new composite unique constraint (tenant_id, info_type) - op.create_unique_constraint("uq_enterprise_info_tenant_type", "enterprise_info", ["tenant_id", "info_type"]) + columns = {str(column["name"]) for column in inspector.get_columns(TABLE_NAME)} + indexes = { + str(index["name"]) + for index in inspector.get_indexes(TABLE_NAME) + if index.get("name") + } + unique_constraints = { + str(constraint["name"]) + for constraint in inspector.get_unique_constraints(TABLE_NAME) + if constraint.get("name") + } + return columns, indexes, unique_constraints + + +def upgrade() -> None: + columns, indexes, unique_constraints = _schema_names( + offline_default=(set(), set(), {LEGACY_UNIQUE}), + ) + + if TENANT_COLUMN not in columns: + op.add_column( + TABLE_NAME, + sa.Column(TENANT_COLUMN, postgresql.UUID(as_uuid=True), nullable=True), + ) + if TENANT_INDEX not in indexes: + op.create_index(TENANT_INDEX, TABLE_NAME, [TENANT_COLUMN], unique=False) + if LEGACY_UNIQUE in unique_constraints: + op.drop_constraint(LEGACY_UNIQUE, TABLE_NAME, type_="unique") + if TENANT_UNIQUE not in unique_constraints: + op.create_unique_constraint( + TENANT_UNIQUE, + TABLE_NAME, + [TENANT_COLUMN, "info_type"], + ) def downgrade() -> None: - op.drop_constraint("uq_enterprise_info_tenant_type", "enterprise_info", type_="unique") - op.create_unique_constraint("enterprise_info_info_type_key", "enterprise_info", ["info_type"]) - op.drop_index(op.f("ix_enterprise_info_tenant_id"), table_name="enterprise_info") - op.drop_column("enterprise_info", "tenant_id") + columns, indexes, unique_constraints = _schema_names( + offline_default=( + {TENANT_COLUMN, "info_type"}, + {TENANT_INDEX}, + {TENANT_UNIQUE}, + ), + ) + + if TENANT_UNIQUE in unique_constraints: + op.drop_constraint(TENANT_UNIQUE, TABLE_NAME, type_="unique") + if LEGACY_UNIQUE not in unique_constraints: + op.create_unique_constraint(LEGACY_UNIQUE, TABLE_NAME, ["info_type"]) + if TENANT_INDEX in indexes: + op.drop_index(TENANT_INDEX, table_name=TABLE_NAME) + if TENANT_COLUMN in columns: + op.drop_column(TABLE_NAME, TENANT_COLUMN) diff --git a/backend/alembic/versions/v1_11_3_f062_tool_execution_identity.py b/backend/alembic/versions/v1_11_3_f062_tool_execution_identity.py new file mode 100644 index 000000000..50931f935 --- /dev/null +++ b/backend/alembic/versions/v1_11_3_f062_tool_execution_identity.py @@ -0,0 +1,70 @@ +"""Separate provider correlation from Runtime Tool Call identity. + +Background: + Provider-local Tool Call IDs can repeat across Assistant turns, while the + Runtime Receipt requires a Run-local stable Call Instance identity. + +Scope: + Add nullable provider_call_id and contract_version columns to + agent_tool_executions. Existing rows remain valid without a data backfill. + +Idempotent: + Each nullable column is added or removed only when its current schema state + requires the DDL operation. + +Revision ID: f062_tool_execution_identity +Revises: f061_enterprise_info_tenant_id +Create Date: 2026-08-10 00:00:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f062_tool_execution_identity" +down_revision: str | None = "f061_enterprise_info_tenant_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE_NAME = "agent_tool_executions" +PROVIDER_CALL_ID = "provider_call_id" +CONTRACT_VERSION = "contract_version" + + +def _column_names(*, offline_default: set[str]) -> set[str]: + try: + inspector = sa.inspect(op.get_bind()) + except sa.exc.NoInspectionAvailable: + return offline_default + return { + str(column["name"]) + for column in inspector.get_columns(TABLE_NAME) + } + + +def upgrade() -> None: + columns = _column_names(offline_default=set()) + if PROVIDER_CALL_ID not in columns: + op.add_column( + TABLE_NAME, + sa.Column(PROVIDER_CALL_ID, sa.String(length=255), nullable=True), + ) + if CONTRACT_VERSION not in columns: + op.add_column( + TABLE_NAME, + sa.Column(CONTRACT_VERSION, sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + columns = _column_names( + offline_default={PROVIDER_CALL_ID, CONTRACT_VERSION} + ) + if CONTRACT_VERSION in columns: + op.drop_column(TABLE_NAME, CONTRACT_VERSION) + if PROVIDER_CALL_ID in columns: + op.drop_column(TABLE_NAME, PROVIDER_CALL_ID) diff --git a/backend/app/models/agent_tool_execution.py b/backend/app/models/agent_tool_execution.py index 1aaf66656..3232aae50 100644 --- a/backend/app/models/agent_tool_execution.py +++ b/backend/app/models/agent_tool_execution.py @@ -74,6 +74,8 @@ class AgentToolExecution(Base): UUID(as_uuid=True), nullable=False ) tool_call_id: Mapped[str] = mapped_column(String(255), nullable=False) + provider_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + contract_version: Mapped[str | None] = mapped_column(String(255), nullable=True) tool_name: Mapped[str] = mapped_column(String(200), nullable=False) assistant_message_id: Mapped[str] = mapped_column(String(255), nullable=False) arguments_hash: Mapped[str] = mapped_column(String(128), nullable=False) diff --git a/backend/app/services/agent_runtime/a2a_runtime.py b/backend/app/services/agent_runtime/a2a_runtime.py index 1754e492d..142f1d6a7 100644 --- a/backend/app/services/agent_runtime/a2a_runtime.py +++ b/backend/app/services/agent_runtime/a2a_runtime.py @@ -2,10 +2,10 @@ from __future__ import annotations +import uuid from dataclasses import dataclass from datetime import UTC, datetime from typing import Literal -import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +22,7 @@ from app.models.chat_session import ChatSession from app.models.gateway_message import GatewayMessage from app.models.org import AgentAgentRelationship +from app.services import agent_directory from app.services.agent_runtime.adapter import RuntimeCommandIntake from app.services.agent_runtime.command_worker import RuntimeSessionFactory from app.services.agent_runtime.config import decide_runtime_v2 @@ -36,10 +37,8 @@ mark_tool_execution_failed, mark_tool_execution_succeeded, ) -from app.services import agent_directory from app.services.participant_identity import get_or_create_agent_participant - A2AMode = Literal["notify", "consult", "task_delegate"] _RESPONSE_MODES = frozenset({"consult", "task_delegate"}) @@ -948,6 +947,16 @@ async def execute( "source_agent_id": str(source_agent.id), "source_agent_name": source_agent.name, "source_run_id": str(source_run.id), + "source_call_instance_id": tool_call_id, + "source_provider_call_id": ( + reservation.execution.provider_call_id + ), + "source_tool_execution_id": str( + reservation.execution.id + ), + "source_tool_contract_version": ( + reservation.execution.contract_version + ), "correlation_id": correlation_id, }, actor_user_id=owner_user_id, @@ -978,6 +987,16 @@ async def execute( status="succeeded", result_summary=execution.result_summary, result_ref=execution.result_ref, + metadata={ + "execution_id": str(reservation.execution.id), + "call_instance_id": tool_call_id, + "provider_call_id": ( + reservation.execution.provider_call_id + ), + "contract_version": ( + reservation.execution.contract_version + ), + }, ), target_run_id=target_run_id, waiting_request=waiting_request, diff --git a/backend/app/services/agent_runtime/async_tool_poll.py b/backend/app/services/agent_runtime/async_tool_poll.py index 79c66f1a5..24cc55ee6 100644 --- a/backend/app/services/agent_runtime/async_tool_poll.py +++ b/backend/app/services/agent_runtime/async_tool_poll.py @@ -2,11 +2,11 @@ from __future__ import annotations +import uuid from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Literal -import uuid from sqlalchemy import false, func, select @@ -14,7 +14,6 @@ from app.services.agent_runtime.command_worker import RuntimeSessionFactory from app.services.agent_runtime.persistence import enqueue_resume - AsyncToolPollStatus = Literal["idle", "deferred", "scheduled"] @@ -211,6 +210,22 @@ async def run_once(self) -> AsyncToolPollResult: "payload": { "operation_key": operation_key, "tool_call_id": execution.tool_call_id, + "call_instance_id": execution.tool_call_id, + "tool_execution_id": str(execution.id), + **( + {"provider_call_id": execution.provider_call_id} + if execution.provider_call_id is not None + else {} + ), + **( + { + "tool_contract_version": ( + execution.contract_version + ) + } + if execution.contract_version is not None + else {} + ), "poll_call_id": poll_call_id, "poll": { "tool": poll_tool_name, diff --git a/backend/app/services/agent_runtime/cancel_source.py b/backend/app/services/agent_runtime/cancel_source.py index f843b5ab2..b8c4dc2c1 100644 --- a/backend/app/services/agent_runtime/cancel_source.py +++ b/backend/app/services/agent_runtime/cancel_source.py @@ -2,8 +2,10 @@ from __future__ import annotations -from collections.abc import Mapping import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Protocol from sqlalchemy import select @@ -11,6 +13,41 @@ from app.services.agent_runtime.command_worker import RuntimeSessionFactory from app.services.agent_runtime.node_executor import CancelSignal from app.services.agent_runtime.state import RuntimeContext, RuntimeGraphState +from app.services.agent_runtime.tool_contracts import ToolCancelCapability + + +class CancelPollSource(Protocol): + async def get_cancel( + self, + state: RuntimeGraphState, + context: RuntimeContext, + ) -> CancelSignal | None: ... + + +@dataclass(frozen=True, slots=True) +class RuntimeToolCancelToken: + """Poll durable Run cancellation and describe adapter capability.""" + + source: CancelPollSource + state: RuntimeGraphState + context: RuntimeContext + capability: ToolCancelCapability + + async def poll(self) -> CancelSignal | None: + return await self.source.get_cancel(self.state, self.context) + + def telemetry(self, signal: CancelSignal) -> dict[str, object]: + return { + "cancel_requested": True, + "cancel_command_id": signal.command_id, + "cancel_reason": signal.reason, + "cancel_capability": self.capability, + "cancel_propagation": ( + "cooperative_task_cancelled" + if self.capability == "cooperative" + else "stop_waiting_only" + ), + } class RuntimeCancelSourceError(RuntimeError): @@ -84,4 +121,5 @@ async def get_cancel( __all__ = [ "DatabaseRuntimeCancelSource", "RuntimeCancelSourceError", + "RuntimeToolCancelToken", ] diff --git a/backend/app/services/agent_runtime/checkpoint_side_effects.py b/backend/app/services/agent_runtime/checkpoint_side_effects.py index d1e9136be..46da103bb 100644 --- a/backend/app/services/agent_runtime/checkpoint_side_effects.py +++ b/backend/app/services/agent_runtime/checkpoint_side_effects.py @@ -2,12 +2,12 @@ from __future__ import annotations +import json +import uuid from collections.abc import Mapping, Sequence from dataclasses import replace from datetime import UTC, datetime, timedelta -import json from typing import Protocol, cast -import uuid from loguru import logger from sqlalchemy import select @@ -31,14 +31,27 @@ deliver_runtime_message, ) from app.services.agent_runtime.state import runtime_messages_as_json -from app.services.agent_runtime.tool_execution import sanitize_tool_arguments +from app.services.agent_runtime.tool_execution import ( + sanitize_tool_arguments, + sanitize_tool_feedback_text, +) from app.services.builtin_tool_definitions import builtin_sensitive_paths -from app.services.group_realtime import publish_stored_group_message from app.services.experience_retrieval import record_experience_citations - +from app.services.group_realtime import publish_stored_group_message _TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) _WAITING_PROMPT = "需要你的确认或补充信息后才能继续。" +_MODEL_ACTIONS = frozenset( + { + "continue", + "repair_arguments", + "choose_other_tool", + "ask_user", + "wait", + "reconcile", + } +) +_SIDE_EFFECT_STATES = frozenset({"none", "confirmed", "possible", "unknown"}) class RuntimeCheckpointSideEffectError(RuntimeError): @@ -268,6 +281,31 @@ def _tool_arguments(call: Mapping[str, object], tool_name: str) -> dict: ) +def _tool_feedback(message: Mapping[str, object]) -> dict[str, str]: + feedback: dict[str, str] = {} + model_action = _text_field(message.get("model_action")) + if model_action in _MODEL_ACTIONS: + feedback["model_action"] = model_action + side_effect_state = _text_field(message.get("side_effect_state")) + if side_effect_state in _SIDE_EFFECT_STATES: + feedback["side_effect_state"] = side_effect_state + remediation = _text_field(message.get("safe_remediation")) + if remediation is not None: + remediation = sanitize_tool_feedback_text(remediation) + if remediation: + feedback["safe_remediation"] = remediation + return feedback + + +def _tool_result_identity(message: Mapping[str, object]) -> dict[str, str]: + identity: dict[str, str] = {} + for field in ("execution_id", "provider_call_id", "contract_version"): + value = _text_field(message.get(field)) + if value is not None: + identity[field] = value[:255] + return identity + + def _runtime_observation_events( run: RuntimeRunRecord, checkpoint: CheckpointObservation, @@ -324,6 +362,16 @@ def _runtime_observation_events( raw_calls = message.get("tool_calls") if not isinstance(raw_calls, list): continue + provider_call_ids = message.get("provider_call_ids") + if not isinstance(provider_call_ids, Mapping): + additional_kwargs = message.get("additional_kwargs") + provider_call_ids = ( + additional_kwargs.get("provider_call_ids") + if isinstance(additional_kwargs, Mapping) + else {} + ) + if not isinstance(provider_call_ids, Mapping): + provider_call_ids = {} for raw_call in raw_calls: if not isinstance(raw_call, Mapping): continue @@ -334,11 +382,17 @@ def _runtime_observation_events( continue detail = { "call_id": call_id, + "call_instance_id": call_id, "name": tool_name, "args": _tool_arguments(raw_call, tool_name), "reasoning_content": reasoning or "", "assistant_message_id": message_id, } + provider_call_id = _text_field( + raw_call.get("provider_call_id") or provider_call_ids.get(call_id) + ) + if provider_call_id is not None: + detail["provider_call_id"] = provider_call_id calls[call_id] = detail events.append( ( @@ -370,6 +424,8 @@ def _runtime_observation_events( **calls[call_id], "result": result, "execution_status": execution_status, + **_tool_feedback(message), + **_tool_result_identity(message), } if error_code is not None: payload["error_code"] = error_code @@ -428,12 +484,15 @@ async def _record_direct_tool_history( "execution_status": execution_status, "result": str(message.get("content") or ""), "tool_call_id": call_id, + "call_instance_id": call_id, "reasoning_content": detail.get("reasoning_content") or "", **( {"error_code": message["error_code"]} if isinstance(message.get("error_code"), str) else {} ), + **_tool_feedback(message), + **_tool_result_identity(message), }, ensure_ascii=False, default=str, @@ -807,8 +866,8 @@ async def handle( __all__ = [ - "RuntimeCheckpointSideEffectError", "RuntimeCheckpointProductHandler", + "RuntimeCheckpointSideEffectError", "RuntimeCheckpointSideEffects", "delivery_from_checkpoint", ] diff --git a/backend/app/services/agent_runtime/model_step_service.py b/backend/app/services/agent_runtime/model_step_service.py index ec6e84961..c394022e6 100644 --- a/backend/app/services/agent_runtime/model_step_service.py +++ b/backend/app/services/agent_runtime/model_step_service.py @@ -3,14 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Mapping, Sequence -from copy import deepcopy -from dataclasses import asdict, replace +import hashlib import json import random import re -from typing import Protocol, cast import uuid +from collections.abc import Awaitable, Callable, Mapping, Sequence +from copy import deepcopy +from dataclasses import asdict, replace +from typing import Protocol, cast from loguru import logger from sqlalchemy import select @@ -19,30 +20,35 @@ from app.models.agent import Agent from app.models.agent_run_command import AgentRunCommand from app.models.agent_tool_execution import AgentToolExecution -from app.models.llm import LLMModel from app.models.group import GroupMember +from app.models.llm import LLMModel from app.models.participant import Participant from app.services.agent_context import build_agent_context from app.services.agent_runtime.command_worker import RuntimeSessionFactory from app.services.agent_runtime.context_builder import ( - ContextBuildError, ContextBuilder, + ContextBuildError, RuntimeContextBuild, ) from app.services.agent_runtime.group_at import ( AT_TOOL_NAME, group_at_tool_definition, ) -from app.services.agent_runtime.group_runtime_tools import with_group_runtime_tools from app.services.agent_runtime.group_handoff import ( GroupAgentHandoffError, preflight_group_agent_handoff, ) +from app.services.agent_runtime.group_runtime_tools import ( + GROUP_READ_TOOL_NAMES, + GROUP_WRITE_TOOL_NAMES, + with_group_runtime_tools, +) from app.services.agent_runtime.model_capabilities import ( ModelCapabilityError, ModelCapabilityResolver, ) from app.services.agent_runtime.node_executor import ModelStepResult +from app.services.agent_runtime.run_compactor import RunCompactInputs from app.services.agent_runtime.state import ( JsonObject, JsonValue, @@ -50,16 +56,34 @@ RuntimeGraphState, runtime_messages_as_json, ) -from app.services.agent_runtime.run_compactor import RunCompactInputs +from app.services.agent_runtime.thread_visibility import ( + model_visible_thread_messages, +) +from app.services.agent_runtime.tool_contracts import ( + AcceptedToolCall, + StepToolContext, + ToolBindingKind, + ToolContractError, + ToolEffect, + ToolExecutionBinding, + ToolRetryPolicy, + ToolWorksetEntry, + deadline_policy_for_tool, + workset_version, +) from app.services.agent_runtime.tool_result_store import ( ToolResultStore, ToolResultStoreError, ) -from app.services.agent_runtime.thread_visibility import ( - model_visible_thread_messages, +from app.services.agent_runtime.tool_registry import ( + resolve_registered_tool, ) from app.services.agent_tools import get_runtime_agent_tools_for_llm -from app.services.vision_inject import compress_bytes_to_base64 +from app.services.builtin_tool_definitions import ( + BUILTIN_TOOL_NAMES, + builtin_policy, + is_reserved_custom_tool_name, +) from app.services.llm.client import LLMMessage from app.services.llm.failover import FailoverErrorType, classify_error from app.services.llm.finish import ( @@ -68,6 +92,7 @@ parse_legacy_finish_content, parse_tool_arguments, ) +from app.services.llm.model_resolution import active_agent_model_candidates from app.services.llm.multimodal_content import ( MultimodalContentError, estimate_multimodal_tokens, @@ -75,9 +100,8 @@ parse_multimodal_content, ) from app.services.llm.single_step import LLMCompletionStep, complete_llm_once -from app.services.llm.model_resolution import active_agent_model_candidates from app.services.llm.utils import get_max_tokens - +from app.services.vision_inject import compress_bytes_to_base64 _ACTIVE_AGENT_STATUSES = frozenset({"creating", "running", "idle"}) _LEDGER_METADATA_KEY = "__clawith_tool_execution__" @@ -199,6 +223,14 @@ def _pending_group_at_participant_ids( return tuple(cast(str, participant_id) for participant_id in participant_ids) +def _tool_repair_reset_reason(state: RuntimeGraphState) -> str | None: + raw = state["lifecycle"].get("tool_repair_reset") + if not isinstance(raw, Mapping): + return None + reason = raw.get("reason") + return "explicit_user_correction" if reason == "explicit_user_correction" else None + + def _retry_http_status(error: Exception) -> str: match = re.search(r"(? ToolWorksetEntry: + """Join one model definition to a stable, secret-free execution route.""" + name = _tool_name(tool) + if name is None: + raise ToolContractError("Tool Workset entry requires a name") + function = tool.get("function") + if not isinstance(function, Mapping): + raise ToolContractError("Tool Workset entry requires a function object") + raw_schema = function.get("parameters", {"type": "object", "properties": {}}) + if not isinstance(raw_schema, Mapping): + raise ToolContractError("Tool Workset entry parameters must be an object") + schema = cast(JsonObject, deepcopy(dict(raw_schema))) + dynamic_mcp_names = ( + {name} + if name not in BUILTIN_TOOL_NAMES + and not is_reserved_custom_tool_name(name) + else set() + ) + registered = resolve_registered_tool( + tool, + dynamic_mcp_names=dynamic_mcp_names, + ) + if registered is not None: + return registered.to_workset_entry() + if name in GROUP_READ_TOOL_NAMES: + effect, retry_policy = "read", "safe" + binding_kind = "group" + elif name in GROUP_WRITE_TOOL_NAMES: + effect, retry_policy = "write", "conditional" + binding_kind = "group" + else: + policy = builtin_policy(name) + effect = cast(str, policy["effect"]) + retry_policy = cast(str, policy["retry_policy"]) + binding_kind = ( + "group" + if name == AT_TOOL_NAME + else "a2a" + if name == "send_message_to_agent" + else "agentbay" + if name.startswith("agentbay_") + else "builtin" + if name in BUILTIN_TOOL_NAMES + else "legacy" + ) + contract_payload = json.dumps( + {"name": name, "schema": schema, "binding_kind": binding_kind}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + contract_digest = hashlib.sha256(contract_payload).hexdigest()[:16] + return ToolWorksetEntry( + tool_name=name, + contract_version=f"runtime:{name}:{contract_digest}", + parameters_schema=schema, + binding=ToolExecutionBinding( + kind=cast(ToolBindingKind, binding_kind), + handler_key=name, + ), + effect=cast(ToolEffect, effect), + retry_policy=cast(ToolRetryPolicy, retry_policy), + deadline_policy=deadline_policy_for_tool(name).name, + ) + + +def _step_tool_context( + state: RuntimeGraphState, + result: ModelStepResult, + tools: Sequence[Mapping[str, object]], +) -> JsonObject: + if result.assistant_message is None: + raise ToolContractError("accepted Tool Calls require an Assistant message") + assistant_message_id = result.assistant_message.get("id") + if not isinstance(assistant_message_id, str) or not assistant_message_id: + raise ToolContractError("accepted Tool Calls require a stable Assistant message ID") + entries = tuple(_runtime_workset_entry(tool) for tool in tools) + entries_by_name = {entry.tool_name: entry for entry in entries} + accepted_calls: list[AcceptedToolCall] = [] + for call in result.tool_calls: + call_id = call.get("id") + provider_call_id = call.get("provider_call_id") + tool_name = _tool_name(call) + if ( + not isinstance(call_id, str) + or not isinstance(provider_call_id, str) + or tool_name not in entries_by_name + ): + raise ToolContractError("accepted Tool Call is missing from its Workset") + accepted_calls.append( + AcceptedToolCall( + call_instance_id=call_id, + provider_call_id=provider_call_id, + entry=entries_by_name[tool_name], + ) + ) + return StepToolContext( + assistant_message_id=assistant_message_id, + model_step=int(state["lifecycle"].get("model_step_count", 0)) + 1, + workset_version=workset_version(entries), + accepted_calls=tuple(accepted_calls), + ).to_json() + + def _with_group_instruction( static_prompt: str, state: RuntimeGraphState, @@ -643,6 +779,7 @@ def _prompt_messages( initial_message_id = build.initial_input.get("message_id") initial_message_seen = False seen_message_ids: set[str] = set() + provider_call_ids: dict[str, str] = {} def append_history(raw: Mapping[str, object]) -> None: nonlocal initial_message_seen @@ -662,14 +799,48 @@ def append_history(raw: Mapping[str, object]) -> None: or raw.get("runtime_input") in {"current", "resume"} ) ) + raw_tool_calls = raw.get("tool_calls") + provider_tool_calls: list[dict] | None = None + raw_provider_call_ids = raw.get("provider_call_ids") + if not isinstance(raw_provider_call_ids, Mapping): + additional_kwargs = raw.get("additional_kwargs") + raw_provider_call_ids = ( + additional_kwargs.get("provider_call_ids") + if isinstance(additional_kwargs, Mapping) + else {} + ) + if not isinstance(raw_provider_call_ids, Mapping): + raw_provider_call_ids = {} + if isinstance(raw_tool_calls, list): + provider_tool_calls = [] + for raw_call in raw_tool_calls: + if not isinstance(raw_call, Mapping): + continue + call = deepcopy(dict(raw_call)) + call_instance_id = call.get("id") + provider_call_id = call.pop("provider_call_id", None) + if not isinstance(provider_call_id, str) and isinstance( + call_instance_id, str + ): + provider_call_id = raw_provider_call_ids.get(call_instance_id) + if isinstance(call_instance_id, str) and isinstance( + provider_call_id, str + ): + provider_call_ids[call_instance_id] = provider_call_id + call["id"] = provider_call_id + provider_tool_calls.append(call) + raw_tool_call_id = raw.get("tool_call_id") + provider_tool_call_id = ( + provider_call_ids.get(raw_tool_call_id, raw_tool_call_id) + if isinstance(raw_tool_call_id, str) + else None + ) messages.append( LLMMessage( role=cast(str, role), # type: ignore[arg-type] content=_model_message_content(raw, build), - tool_calls=( - cast(list[dict], raw.get("tool_calls")) if isinstance(raw.get("tool_calls"), list) else None - ), - tool_call_id=(cast(str, raw.get("tool_call_id")) if isinstance(raw.get("tool_call_id"), str) else None), + tool_calls=provider_tool_calls, + tool_call_id=provider_tool_call_id, reasoning_content=( cast(str, raw.get("reasoning_content")) if isinstance(raw.get("reasoning_content"), str) else None ), @@ -756,6 +927,48 @@ def _assistant_message( return message +def _with_call_instances( + context: RuntimeContext, + result: ModelStepResult, +) -> ModelStepResult: + """Replace provider-local IDs with stable Run-local Call Instance IDs.""" + if result.assistant_message is None: + raise ToolContractError("accepted Tool Calls require an Assistant message") + assistant_message_id = result.assistant_message.get("id") + if not isinstance(assistant_message_id, str) or not assistant_message_id: + raise ToolContractError("accepted Tool Calls require a stable Assistant message ID") + run_id = uuid.UUID(context.run_id) + calls: list[JsonObject] = [] + provider_call_ids: dict[str, str] = {} + for index, raw_call in enumerate(result.tool_calls): + provider_call_id = raw_call.get("id") + if not isinstance(provider_call_id, str) or not provider_call_id.strip(): + raise ToolContractError("accepted Tool Call requires a Provider Call ID") + call = cast(JsonObject, deepcopy(raw_call)) + call["id"] = str( + uuid.uuid5( + run_id, + f"call-instance:{assistant_message_id}:{index}", + ) + ) + call["provider_call_id"] = provider_call_id.strip() + provider_call_ids[cast(str, call["id"])] = provider_call_id.strip() + calls.append(call) + assistant_message = cast(JsonObject, deepcopy(result.assistant_message)) + assistant_message["tool_calls"] = [ + {key: value for key, value in call.items() if key != "provider_call_id"} + for call in calls + ] + assistant_message["additional_kwargs"] = { + "provider_call_ids": provider_call_ids, + } + return replace( + result, + assistant_message=assistant_message, + tool_calls=tuple(calls), + ) + + def _repair( state: RuntimeGraphState, context: RuntimeContext, @@ -1590,6 +1803,7 @@ async def complete_once( actual_model = model failed_over_from: LLMModel | None = None active_allowed_names = allowed_names + active_tools = tools try: _log_provider_request_start( context=context, @@ -1717,6 +1931,7 @@ async def complete_once( actual_model = fallback failed_over_from = model active_allowed_names = fallback_allowed_names + active_tools = fallback_tools result = _parse_step( state, @@ -1726,6 +1941,19 @@ async def complete_once( allow_user_wait=allow_user_wait, allow_group_handoff=not allow_user_wait, ) + reset_reason = _tool_repair_reset_reason(state) + if reset_reason is not None: + result = replace(result, repair_reset_reason=reset_reason) + if result.intent == "tool_calls": + result = _with_call_instances(context, result) + result = replace( + result, + step_tool_context=_step_tool_context( + state, + result, + active_tools, + ), + ) if result.intent == "finish" and not allow_user_wait: try: staged_participant_ids = _pending_group_at_participant_ids(state) diff --git a/backend/app/services/agent_runtime/node_executor.py b/backend/app/services/agent_runtime/node_executor.py index 1519b65c5..8a0a00893 100644 --- a/backend/app/services/agent_runtime/node_executor.py +++ b/backend/app/services/agent_runtime/node_executor.py @@ -2,11 +2,12 @@ from __future__ import annotations +import hashlib +import json +import uuid from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -import json from typing import Literal, Protocol, cast -import uuid from langchain_core.messages import RemoveMessage from langgraph.graph.message import REMOVE_ALL_MESSAGES @@ -21,6 +22,11 @@ RuntimeStateUpdate, runtime_messages_as_json, ) +from app.services.agent_runtime.tool_repair_budget import ( + ToolRepairBudgetError, + apply_tool_result, + reset_tool_repair_episodes, +) from app.services.llm.caller import ( WRITE_FILE_PROTOCOL_FAILURE_MESSAGE, WRITE_FILE_PROTOCOL_REPAIR_COUNTER_KEY, @@ -28,7 +34,6 @@ ) from app.services.llm.multimodal_content import parse_multimodal_content - _TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) _WAITING_STATUSES = frozenset({"waiting_user", "waiting_external", "waiting_agent"}) @@ -47,7 +52,7 @@ def __init__(self, code: str, message: str) -> None: class RuntimeInvocationCancelled(RuntimeError): """Stop an invocation without committing a synthetic cancelled checkpoint.""" - def __init__(self, signal: "CancelSignal") -> None: + def __init__(self, signal: CancelSignal) -> None: super().__init__(signal.reason or "runtime invocation cancelled") self.cancel_command_id = signal.command_id self.reason = signal.reason @@ -68,6 +73,7 @@ class ModelStepResult: intent: ModelIntent assistant_message: JsonObject | None = None tool_calls: tuple[JsonObject, ...] = () + step_tool_context: JsonObject | None = None waiting_request: JsonObject | None = None finish_content: str | None = None finish_mention_participant_ids: tuple[str, ...] = () @@ -75,6 +81,7 @@ class ModelStepResult: repair_instruction: str | None = None repair_code: str | None = None repair_tool_name: str | None = None + repair_reset_reason: str | None = None error: JsonObject | None = None @@ -85,6 +92,7 @@ class ToolStepResult: messages: tuple[JsonObject, ...] = () waiting_request: JsonObject | None = None pending_tool_calls: tuple[JsonObject, ...] = () + step_tool_context: JsonObject | None = None pending_group_at_changed: bool = False pending_group_at: JsonObject | None = None cancel_signal: CancelSignal | None = None @@ -333,6 +341,74 @@ def _tool_calls(lifecycle: RuntimeLifecycle) -> tuple[JsonObject, ...]: return tuple(dict(cast(Mapping[str, JsonValue], call)) for call in value) +def _tool_call_name(call: Mapping[str, object]) -> str: + function = call.get("function") + name = function.get("name") if isinstance(function, Mapping) else call.get("name") + return name.strip() if isinstance(name, str) and name.strip() else "unknown_tool" + + +def _paused_tail_result( + context: RuntimeContext, + call: Mapping[str, object], +) -> JsonObject: + call_id = str(call.get("id") or "") + return { + "id": _runtime_message_id(context, f"tool-repair-paused:{call_id}"), + "role": "tool", + "tool_call_id": call_id, + "name": _tool_call_name(call), + "content": "Tool execution was skipped because the repair episode paused.", + "execution_status": "failed", + "error_code": "tool_batch_paused", + "model_action": "ask_user", + "side_effect_state": "none", + "safe_remediation": "Wait for corrected user input before proposing Tools again.", + } + + +def _verification_fingerprint(verification: VerificationResult) -> str: + payload = json.dumps( + { + "code": verification.details.get("code"), + "reason": verification.reason, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return f"sha256:{hashlib.sha256(payload.encode()).hexdigest()}" + + +def _verification_repair_attempt( + lifecycle: RuntimeLifecycle, + verification: VerificationResult, +) -> tuple[int, JsonObject]: + fingerprint = _verification_fingerprint(verification) + raw = lifecycle.get("verification_repair_episode") + if raw is not None and not isinstance(raw, Mapping): + raise RuntimeNodeTransitionError( + "invalid_verification_repair_episode", + "checkpoint verification repair episode must be an object", + ) + prior_fingerprint = raw.get("fingerprint") if isinstance(raw, Mapping) else None + prior_attempts = raw.get("attempts", 0) if isinstance(raw, Mapping) else 0 + if ( + isinstance(prior_attempts, bool) + or not isinstance(prior_attempts, int) + or prior_attempts < 0 + ): + raise RuntimeNodeTransitionError( + "invalid_verification_repair_episode", + "checkpoint verification repair attempts must be non-negative", + ) + attempts = prior_attempts + 1 if prior_fingerprint == fingerprint else 1 + return attempts, { + "fingerprint": fingerprint, + "attempts": attempts, + "issue_code": verification.details.get("code"), + } + + def _error(code: str, message: str) -> JsonObject: return {"code": code, "message": message} @@ -603,6 +679,17 @@ async def _model( result = await self._model_service.complete_once(state, context) lifecycle["model_step_count"] = step_count + if result.repair_reset_reason is not None: + if result.repair_reset_reason != "explicit_user_correction": + raise RuntimeNodeTransitionError( + "invalid_tool_repair_reset", + "model repair reset reason is unsupported", + ) + lifecycle["tool_repair_reset"] = { + "reason": result.repair_reset_reason, + "command_id": context.command_id, + "consumed_at_model_step": step_count, + } if result.intent != "finish": lifecycle.pop("finish_delivery_intent", None) new_messages: list[JsonObject] = [] @@ -619,6 +706,14 @@ async def _model( "invalid_model_intent", "tool_calls intent requires at least one call", ) + if result.step_tool_context is not None and not isinstance( + result.step_tool_context, + Mapping, + ): + raise RuntimeNodeTransitionError( + "invalid_step_tool_context", + "tool_calls intent Step Tool Context must be an object", + ) lifecycle.update( { "status": "running", @@ -626,6 +721,8 @@ async def _model( "pending_tool_calls": [dict(call) for call in result.tool_calls], } ) + if result.step_tool_context is not None: + lifecycle["step_tool_context"] = dict(result.step_tool_context) elif result.intent == "wait": request = _validate_waiting_request(result.waiting_request) waiting_type = cast(str, request["waiting_type"]) @@ -681,6 +778,8 @@ async def _model( repair_limit = ( WRITE_FILE_PROTOCOL_REPAIR_LIMIT if is_write_file_repair + else 10 + if repair_code == "invalid_tool_call" else 1 ) repair_counter_key = ( @@ -846,11 +945,38 @@ async def _tool( ) pending_calls = (*result.pending_tool_calls, *tail_calls) lifecycle = dict(state["lifecycle"]) + repair_pause_reason: str | None = None + repair_pause_tool: str | None = None + try: + repair_episodes: object = lifecycle.get("tool_repair_episodes") + for message in result.messages: + transition = apply_tool_result( + repair_episodes, + message, + model_step=_counter(state["lifecycle"], "model_step_count"), + ) + repair_episodes = transition.episodes + if transition.pause_reason is not None: + repair_pause_reason = transition.pause_reason + repair_pause_tool = transition.paused_tool_name + lifecycle["tool_repair_episodes"] = cast(JsonObject, repair_episodes) + except ToolRepairBudgetError as exc: + raise RuntimeNodeTransitionError( + "invalid_tool_repair_episodes", + str(exc), + ) from exc lifecycle.update( { "pending_tool_calls": [dict(call) for call in pending_calls], } ) + if result.step_tool_context is not None: + if not isinstance(result.step_tool_context, Mapping): + raise RuntimeNodeTransitionError( + "invalid_step_tool_context", + "Tool Step context update must be an object", + ) + lifecycle["step_tool_context"] = dict(result.step_tool_context) if result.pending_group_at_changed: if result.pending_group_at is None: lifecycle.pop("pending_group_at", None) @@ -890,6 +1016,30 @@ async def _tool( "error": dict(result.error), } ) + elif repair_pause_reason is not None: + lifecycle.pop("step_tool_context", None) + lifecycle.update( + { + "status": "waiting_user", + "next_route": "wait", + "reason": repair_pause_reason, + "pending_tool_calls": [], + "waiting_request": { + "waiting_type": "user", + "correlation_id": _runtime_message_id( + context, + f"tool-repair:{repair_pause_reason}:{repair_pause_tool}", + ), + "reason": repair_pause_reason, + "question": ( + f"Tool {repair_pause_tool or 'unknown'} reached its " + "repair safety limit. Provide corrected requirements " + "or arguments to continue." + ), + }, + "error": None, + } + ) else: lifecycle.update( { @@ -901,6 +1051,7 @@ async def _tool( if pending_calls: lifecycle["next_route"] = "tool" else: + lifecycle.pop("step_tool_context", None) _schedule_compact(lifecycle) update: RuntimeStateUpdate = { "lifecycle": cast(RuntimeLifecycle, lifecycle), @@ -908,6 +1059,11 @@ async def _tool( output_messages = [ _message_for_channel(dict(message)) for message in result.messages ] + if repair_pause_reason is not None: + output_messages.extend( + _message_for_channel(_paused_tail_result(context, call)) + for call in tail_calls + ) if ( result.cancel_signal is None and result.waiting_request is None @@ -966,6 +1122,8 @@ async def _verify( "details": dict(verification.details), } if verification.outcome == "pass": + lifecycle.pop("verification_repair_episode", None) + lifecycle["verification_attempt_count"] = 0 finalized = await self._finalizer.finalize( state, context, @@ -1008,8 +1166,12 @@ async def _verify( ) elif verification.outcome == "repair": lifecycle.pop("finish_delivery_intent", None) - attempts = _counter(state["lifecycle"], "verification_attempt_count") + 1 + attempts, verification_episode = _verification_repair_attempt( + state["lifecycle"], + verification, + ) lifecycle["verification_attempt_count"] = attempts + lifecycle["verification_repair_episode"] = verification_episode if attempts > self._max_verification_repairs: lifecycle.pop("pending_group_at", None) lifecycle.update( @@ -1105,6 +1267,32 @@ async def _wait( "runtime_input": "resume", "runtime_run_id": context.run_id, }) + if ( + waiting_status == "waiting_user" + and state["lifecycle"].get("reason") + in { + "tool_repair_same_fingerprint_limit_reached", + "tool_repair_episode_limit_reached", + } + and resume_value.get("resume_type") == "user_input" + ): + try: + lifecycle["tool_repair_episodes"] = reset_tool_repair_episodes( + lifecycle.get("tool_repair_episodes") + ) + except ToolRepairBudgetError as exc: + raise RuntimeNodeTransitionError( + "invalid_tool_repair_episodes", + str(exc), + ) from exc + lifecycle["tool_repair_reset"] = { + "reason": "explicit_user_correction", + "command_id": context.command_id, + "at_model_step": _counter( + state["lifecycle"], + "model_step_count", + ), + } pending_calls = _tool_calls(cast(RuntimeLifecycle, lifecycle)) if waiting_status == "waiting_user" and pending_calls: deferred = lifecycle.get("deferred_resume_messages", []) @@ -1195,10 +1383,10 @@ async def execute( "RunCompactResult", "RuntimeCancelSource", "RuntimeFinalizer", - "RuntimeModelStepService", - "RuntimeRunCompactor", "RuntimeInvocationCancelled", + "RuntimeModelStepService", "RuntimeNodeTransitionError", + "RuntimeRunCompactor", "RuntimeToolStepService", "RuntimeVerifier", "ToolStepResult", diff --git a/backend/app/services/agent_runtime/state.py b/backend/app/services/agent_runtime/state.py index bc53111a9..51ec1bee7 100644 --- a/backend/app/services/agent_runtime/state.py +++ b/backend/app/services/agent_runtime/state.py @@ -8,7 +8,6 @@ from langchain_core.messages import AnyMessage, BaseMessage, convert_to_openai_messages from langgraph.graph.message import add_messages - JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] JsonObject: TypeAlias = dict[str, JsonValue] @@ -105,8 +104,12 @@ class RuntimeLifecycle(TypedDict): reason: NotRequired[str | None] model_step_count: NotRequired[int] model_protocol_repairs: NotRequired[JsonObject] + tool_repair_episodes: NotRequired[JsonObject] + tool_repair_reset: NotRequired[JsonObject] verification_attempt_count: NotRequired[int] + verification_repair_episode: NotRequired[JsonObject] pending_tool_calls: NotRequired[list[JsonObject]] + step_tool_context: NotRequired[JsonObject | None] pending_group_at: NotRequired[JsonObject | None] deferred_resume_messages: NotRequired[list[JsonObject]] waiting_request: NotRequired[JsonObject | None] @@ -185,7 +188,7 @@ async def execute( self, node: RuntimeNodeName, state: RuntimeGraphState, - context: "RuntimeContext", + context: RuntimeContext, *, resume_value: JsonValue | None = None, ) -> RuntimeStateUpdate: ... diff --git a/backend/app/services/agent_runtime/tool_contracts.py b/backend/app/services/agent_runtime/tool_contracts.py new file mode 100644 index 000000000..0096a83b2 --- /dev/null +++ b/backend/app/services/agent_runtime/tool_contracts.py @@ -0,0 +1,501 @@ +"""Checkpoint-safe Tool Workset and accepted-call contracts. + +These values contain execution routing facts, never live clients, callables, or +decrypted credentials. The LangGraph checkpoint owns them because they decide +how an already accepted Tool Call resumes. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Literal, cast + +from app.services.agent_runtime.state import JsonObject, JsonValue + +ToolBindingKind = Literal["builtin", "mcp", "group", "a2a", "agentbay", "legacy"] +ToolEffect = Literal["read", "write", "external_write"] +ToolRetryPolicy = Literal["safe", "conditional", "never"] +ToolCancelCapability = Literal["cooperative", "stop_waiting_only"] + +STEP_TOOL_CONTEXT_VERSION = 1 +MAX_TOOL_CONTEXT_BYTES = 256 * 1024 +MAX_TOOL_SCHEMA_BYTES = 64 * 1024 +MAX_TOOL_BINDING_BYTES = 16 * 1024 +MAX_ID_LENGTH = 255 +MAX_TOOL_NAME_LENGTH = 200 + +_SENSITIVE_KEYS = { + "access_token", + "api_key", + "apikey", + "authorization", + "bearer", + "client_secret", + "cookie", + "password", + "private_key", + "refresh_token", + "secret", + "token", +} + + +class ToolContractError(ValueError): + """A checkpoint Tool contract is missing, malformed, or unsafe.""" + + +@dataclass(frozen=True, slots=True) +class ToolDeadlinePolicy: + name: str + default_seconds: float + max_seconds: float + cancel_capability: ToolCancelCapability + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name.strip(): + raise ToolContractError("deadline policy name must be non-empty text") + if self.default_seconds <= 0 or self.max_seconds < self.default_seconds: + raise ToolContractError("deadline policy bounds are invalid") + + +_DEADLINE_POLICIES = { + "runtime_default": ToolDeadlinePolicy( + "runtime_default", 60.0, 300.0, "stop_waiting_only" + ), + "network_read": ToolDeadlinePolicy( + "network_read", 30.0, 60.0, "stop_waiting_only" + ), + "local_code": ToolDeadlinePolicy( + "local_code", 30.0, 3600.0, "cooperative" + ), + "agentbay_read": ToolDeadlinePolicy( + "agentbay_read", 30.0, 60.0, "stop_waiting_only" + ), + "agentbay_code": ToolDeadlinePolicy( + "agentbay_code", 30.0, 300.0, "stop_waiting_only" + ), +} + + +def deadline_policy_for_tool(tool_name: str) -> ToolDeadlinePolicy: + if tool_name in {"execute_code", "execute_code_e2b"}: + return _DEADLINE_POLICIES["local_code"] + if tool_name == "agentbay_code_execute": + return _DEADLINE_POLICIES["agentbay_code"] + if tool_name in { + "agentbay_code_read_file", + "agentbay_browser_extract", + "agentbay_browser_observe", + }: + return _DEADLINE_POLICIES["agentbay_read"] + if tool_name in {"read_emails", "read_webpage", "jina_read"}: + return _DEADLINE_POLICIES["network_read"] + return _DEADLINE_POLICIES["runtime_default"] + + +def resolve_tool_deadline_seconds( + policy_name: str, + requested_seconds: object = None, +) -> float: + policy = _DEADLINE_POLICIES.get(policy_name) + if policy is None: + raise ToolContractError(f"unknown deadline policy {policy_name!r}") + if requested_seconds is None: + return policy.default_seconds + if ( + isinstance(requested_seconds, bool) + or not isinstance(requested_seconds, (int, float)) + or requested_seconds <= 0 + ): + raise ToolContractError("requested Tool deadline must be positive") + return min(float(requested_seconds), policy.max_seconds) + + +def tool_cancel_capability(policy_name: str) -> ToolCancelCapability: + policy = _DEADLINE_POLICIES.get(policy_name) + if policy is None: + raise ToolContractError(f"unknown deadline policy {policy_name!r}") + return policy.cancel_capability + + +def _required_text(value: object, *, field_name: str, max_length: int) -> str: + if not isinstance(value, str) or not value.strip(): + raise ToolContractError(f"{field_name} must be non-empty text") + normalized = value.strip() + if len(normalized) > max_length: + raise ToolContractError(f"{field_name} exceeds its length limit") + return normalized + + +def _optional_text(value: object, *, field_name: str, max_length: int) -> str | None: + if value is None: + return None + return _required_text(value, field_name=field_name, max_length=max_length) + + +def _json_object(value: object, *, field_name: str) -> JsonObject: + if not isinstance(value, Mapping): + raise ToolContractError(f"{field_name} must be one JSON object") + try: + copied = json.loads(json.dumps(value, ensure_ascii=False)) + except (TypeError, ValueError) as exc: + raise ToolContractError(f"{field_name} must be JSON serializable") from exc + if not isinstance(copied, dict): + raise ToolContractError(f"{field_name} must be one JSON object") + return cast(JsonObject, copied) + + +def _json_size(value: object) -> int: + try: + return len( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ) + except (TypeError, ValueError) as exc: + raise ToolContractError("Tool contract must be JSON serializable") from exc + + +def _contains_secret(value: JsonValue) -> bool: + if isinstance(value, dict): + for raw_key, child in value.items(): + key = raw_key.strip().lower().replace("-", "_") + if key in _SENSITIVE_KEYS: + return True + if _contains_secret(child): + return True + elif isinstance(value, list): + return any(_contains_secret(child) for child in value) + return False + + +@dataclass(frozen=True, slots=True) +class ToolExecutionBinding: + """Secret-free stable route for an accepted Tool Call.""" + + kind: ToolBindingKind + handler_key: str + target: JsonObject = field(default_factory=dict) + credential_ref: str | None = None + + def __post_init__(self) -> None: + if self.kind not in {"builtin", "mcp", "group", "a2a", "agentbay", "legacy"}: + raise ToolContractError("binding kind is unsupported") + object.__setattr__( + self, + "handler_key", + _required_text( + self.handler_key, + field_name="binding.handler_key", + max_length=MAX_TOOL_NAME_LENGTH, + ), + ) + target = _json_object(self.target, field_name="binding.target") + if _json_size(target) > MAX_TOOL_BINDING_BYTES: + raise ToolContractError("binding target exceeds its size limit") + if _contains_secret(target): + raise ToolContractError("binding target contains secret material") + object.__setattr__(self, "target", target) + object.__setattr__( + self, + "credential_ref", + _optional_text( + self.credential_ref, + field_name="binding.credential_ref", + max_length=MAX_ID_LENGTH, + ), + ) + + def to_json(self) -> JsonObject: + return { + "kind": self.kind, + "handler_key": self.handler_key, + "target": dict(self.target), + "credential_ref": self.credential_ref, + } + + @classmethod + def from_json(cls, value: object) -> ToolExecutionBinding: + payload = _json_object(value, field_name="binding") + return cls( + kind=cast(ToolBindingKind, payload.get("kind")), + handler_key=cast(str, payload.get("handler_key")), + target=_json_object(payload.get("target", {}), field_name="binding.target"), + credential_ref=cast(str | None, payload.get("credential_ref")), + ) + + +@dataclass(frozen=True, slots=True) +class ToolWorksetEntry: + """One model-visible Tool definition joined to its execution contract.""" + + tool_name: str + contract_version: str + parameters_schema: JsonObject + binding: ToolExecutionBinding + effect: ToolEffect + retry_policy: ToolRetryPolicy + authorization_policy: str = "runtime_default" + deadline_policy: str = "runtime_default" + recovery_policy: str = "runtime_default" + + def __post_init__(self) -> None: + tool_name = _required_text( + self.tool_name, + field_name="tool_name", + max_length=MAX_TOOL_NAME_LENGTH, + ) + object.__setattr__(self, "tool_name", tool_name) + object.__setattr__( + self, + "contract_version", + _required_text( + self.contract_version, + field_name="contract_version", + max_length=MAX_ID_LENGTH, + ), + ) + schema = _json_object(self.parameters_schema, field_name="parameters_schema") + if _json_size(schema) > MAX_TOOL_SCHEMA_BYTES: + raise ToolContractError("parameters schema exceeds its size limit") + object.__setattr__(self, "parameters_schema", schema) + if self.effect not in {"read", "write", "external_write"}: + raise ToolContractError("Tool effect is unsupported") + if self.retry_policy not in {"safe", "conditional", "never"}: + raise ToolContractError("Tool retry policy is unsupported") + for field_name in ( + "authorization_policy", + "deadline_policy", + "recovery_policy", + ): + object.__setattr__( + self, + field_name, + _required_text( + getattr(self, field_name), + field_name=field_name, + max_length=MAX_ID_LENGTH, + ), + ) + if self.deadline_policy not in _DEADLINE_POLICIES: + raise ToolContractError("Tool deadline policy is unsupported") + if self.binding.kind == "builtin" and self.binding.handler_key != tool_name: + raise ToolContractError("builtin binding must match the Tool name") + + def to_json(self) -> JsonObject: + return { + "tool_name": self.tool_name, + "contract_version": self.contract_version, + "parameters_schema": dict(self.parameters_schema), + "binding": self.binding.to_json(), + "effect": self.effect, + "retry_policy": self.retry_policy, + "authorization_policy": self.authorization_policy, + "deadline_policy": self.deadline_policy, + "recovery_policy": self.recovery_policy, + } + + @classmethod + def from_json(cls, value: object) -> ToolWorksetEntry: + payload = _json_object(value, field_name="workset entry") + return cls( + tool_name=cast(str, payload.get("tool_name")), + contract_version=cast(str, payload.get("contract_version")), + parameters_schema=_json_object( + payload.get("parameters_schema"), + field_name="parameters_schema", + ), + binding=ToolExecutionBinding.from_json(payload.get("binding")), + effect=cast(ToolEffect, payload.get("effect")), + retry_policy=cast(ToolRetryPolicy, payload.get("retry_policy")), + authorization_policy=cast( + str, + payload.get("authorization_policy", "runtime_default"), + ), + deadline_policy=cast( + str, + payload.get("deadline_policy", "runtime_default"), + ), + recovery_policy=cast( + str, + payload.get("recovery_policy", "runtime_default"), + ), + ) + + +@dataclass(frozen=True, slots=True) +class AcceptedToolCall: + """One accepted assistant Tool Call and its frozen Workset entry.""" + + call_instance_id: str + provider_call_id: str | None + entry: ToolWorksetEntry + + def __post_init__(self) -> None: + object.__setattr__( + self, + "call_instance_id", + _required_text( + self.call_instance_id, + field_name="call_instance_id", + max_length=MAX_ID_LENGTH, + ), + ) + object.__setattr__( + self, + "provider_call_id", + _optional_text( + self.provider_call_id, + field_name="provider_call_id", + max_length=MAX_ID_LENGTH, + ), + ) + + def to_json(self) -> JsonObject: + return { + "call_instance_id": self.call_instance_id, + "provider_call_id": self.provider_call_id, + **self.entry.to_json(), + } + + @classmethod + def from_json(cls, value: object) -> AcceptedToolCall: + payload = _json_object(value, field_name="accepted call") + return cls( + call_instance_id=cast(str, payload.get("call_instance_id")), + provider_call_id=cast(str | None, payload.get("provider_call_id")), + entry=ToolWorksetEntry.from_json(payload), + ) + + +def workset_version(entries: tuple[ToolWorksetEntry, ...]) -> str: + """Return an order-independent digest of the executable Workset contract.""" + names = [entry.tool_name for entry in entries] + if len(set(names)) != len(names): + raise ToolContractError("Workset contains duplicate Tool names") + payload = [entry.to_json() for entry in sorted(entries, key=lambda item: item.tool_name)] + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +@dataclass(frozen=True, slots=True) +class StepToolContext: + """The exact Tool contract accepted for one Assistant message.""" + + assistant_message_id: str + model_step: int + workset_version: str + accepted_calls: tuple[AcceptedToolCall, ...] + legacy_resolved: bool = False + version: int = STEP_TOOL_CONTEXT_VERSION + + def __post_init__(self) -> None: + if self.version != STEP_TOOL_CONTEXT_VERSION: + raise ToolContractError("Step Tool Context version is unsupported") + if not isinstance(self.legacy_resolved, bool): + raise ToolContractError("legacy_resolved must be a boolean") + object.__setattr__( + self, + "assistant_message_id", + _required_text( + self.assistant_message_id, + field_name="assistant_message_id", + max_length=MAX_ID_LENGTH, + ), + ) + if isinstance(self.model_step, bool) or self.model_step <= 0: + raise ToolContractError("model_step must be a positive integer") + object.__setattr__( + self, + "workset_version", + _required_text( + self.workset_version, + field_name="workset_version", + max_length=MAX_ID_LENGTH, + ), + ) + calls = tuple(self.accepted_calls) + call_ids = [call.call_instance_id for call in calls] + if len(call_ids) != len(set(call_ids)): + raise ToolContractError("Step Tool Context contains duplicate Call Instances") + provider_ids = [call.provider_call_id for call in calls if call.provider_call_id] + if len(provider_ids) != len(set(provider_ids)): + raise ToolContractError("Step Tool Context contains duplicate Provider Call IDs") + object.__setattr__(self, "accepted_calls", calls) + if _json_size(self.to_json()) > MAX_TOOL_CONTEXT_BYTES: + raise ToolContractError("Step Tool Context exceeds its size limit") + + def to_json(self) -> JsonObject: + return { + "version": self.version, + "assistant_message_id": self.assistant_message_id, + "model_step": self.model_step, + "workset_version": self.workset_version, + "accepted_calls": [call.to_json() for call in self.accepted_calls], + "legacy_resolved": self.legacy_resolved, + } + + def accepted_call(self, call_instance_id: str) -> AcceptedToolCall: + matches = [ + call + for call in self.accepted_calls + if call.call_instance_id == call_instance_id + ] + if len(matches) != 1: + raise ToolContractError("Call Instance is missing from Step Tool Context") + return matches[0] + + @classmethod + def from_json(cls, value: object) -> StepToolContext: + payload = _json_object(value, field_name="step_tool_context") + raw_calls = payload.get("accepted_calls") + if not isinstance(raw_calls, list): + raise ToolContractError("accepted_calls must be an array") + return cls( + version=cast(int, payload.get("version")), + assistant_message_id=cast(str, payload.get("assistant_message_id")), + model_step=cast(int, payload.get("model_step")), + workset_version=cast(str, payload.get("workset_version")), + accepted_calls=tuple(AcceptedToolCall.from_json(call) for call in raw_calls), + legacy_resolved=cast(bool, payload.get("legacy_resolved", False)), + ) + + +def parse_step_tool_context( + value: object, + *, + allow_legacy_missing: bool = False, +) -> StepToolContext | None: + """Decode one checkpoint context without silently upgrading corruption.""" + if value is None: + if allow_legacy_missing: + return None + raise ToolContractError("Step Tool Context is missing") + return StepToolContext.from_json(value) + + +__all__ = [ + "AcceptedToolCall", + "StepToolContext", + "ToolCancelCapability", + "ToolContractError", + "ToolDeadlinePolicy", + "ToolExecutionBinding", + "ToolWorksetEntry", + "deadline_policy_for_tool", + "parse_step_tool_context", + "resolve_tool_deadline_seconds", + "tool_cancel_capability", + "workset_version", +] diff --git a/backend/app/services/agent_runtime/tool_execution.py b/backend/app/services/agent_runtime/tool_execution.py index 46310aa19..161b552a2 100644 --- a/backend/app/services/agent_runtime/tool_execution.py +++ b/backend/app/services/agent_runtime/tool_execution.py @@ -7,16 +7,17 @@ from __future__ import annotations -from copy import deepcopy -from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta import hashlib import json import re import unicodedata -from typing import Any, Callable, Literal -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import uuid +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any, Literal, cast +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -25,7 +26,6 @@ from app.models.agent_run import AgentRun from app.models.agent_tool_execution import AgentToolExecution - ToolExecutionStatus = Literal[ "not_started", "started", @@ -35,7 +35,16 @@ ] SideEffectClassification = Literal["read", "write", "external_write"] RetryPolicy = Literal["safe", "conditional", "never"] -SAFE_READ_MAX_ATTEMPTS = 3 +ToolModelAction = Literal[ + "continue", + "repair_arguments", + "choose_other_tool", + "ask_user", + "wait", + "reconcile", +] +ToolSideEffectState = Literal["none", "confirmed", "possible", "unknown"] +SAFE_READ_MAX_ATTEMPTS = 10 # These tools dispatch an external image-generation request and can therefore # leave the provider outcome uncertain after a response timeout. Direct Chat @@ -52,6 +61,18 @@ _PERSISTED_STATUSES = frozenset({"started", "succeeded", "failed", "unknown"}) _SIDE_EFFECT_CLASSIFICATIONS = frozenset({"read", "write", "external_write"}) _RETRY_POLICIES = frozenset({"safe", "conditional", "never"}) +_MODEL_ACTIONS = frozenset( + { + "continue", + "repair_arguments", + "choose_other_tool", + "ask_user", + "wait", + "reconcile", + } +) +_SIDE_EFFECT_STATES = frozenset({"none", "confirmed", "possible", "unknown"}) +_SAFE_REMEDIATION_MAX_BYTES = 512 _METADATA_KEY = "__clawith_tool_execution__" _METADATA_VERSION = 1 _RESULT_METADATA_MAX_BYTES = 16 * 1024 @@ -60,6 +81,13 @@ "error_code", "error_class", "retryable", + "model_action", + "side_effect_state", + "safe_remediation", + "execution_id", + "call_instance_id", + "provider_call_id", + "contract_version", "artifact_refs", "evidence_refs", "nul_replacements", @@ -97,6 +125,8 @@ "status", "changed_fields", "content_truncated", + "document_processed_scope", + "document_truncation_reasons", "okr_content_hash", "stored_character_count", "source", @@ -135,6 +165,16 @@ "runtime_retry_pending", "runtime_retry_exhausted", "last_error_code", + "deadline_policy", + "deadline_seconds", + "deadline_exceeded", + "cancel_requested", + "cancel_command_id", + "cancel_reason", + "cancel_capability", + "cancel_propagation", + "lease_renewed", + "lease_fenced", "runtime_async_pending", "async_operation", "async_poll_due_at", @@ -305,6 +345,14 @@ def _normalize_text(value: str, *, redact: bool) -> tuple[str, int, int, int]: return redacted, nul_replacements, control_replacements, redaction_count +def sanitize_tool_feedback_text(value: str, *, max_bytes: int = 512) -> str: + """Return bounded, secret-redacted text safe for durable projections.""" + if max_bytes <= 0: + raise ValueError("max_bytes must be positive") + normalized, _, _, _ = _normalize_text(value, redact=True) + return _truncate_utf8(normalized.strip(), max_bytes) + + def _sanitize_json(value: Any, *, sensitive: bool = False) -> Any: if sensitive: return "[REDACTED]" @@ -453,6 +501,26 @@ def normalize_tool_outcome( "invalid_tool_outcome", "tool outcome error_code must be a string or null", ) + if outcome.model_action is not None and outcome.model_action not in _MODEL_ACTIONS: + raise ToolExecutionError( + "invalid_tool_outcome", + "tool outcome model_action is invalid", + ) + if ( + outcome.side_effect_state is not None + and outcome.side_effect_state not in _SIDE_EFFECT_STATES + ): + raise ToolExecutionError( + "invalid_tool_outcome", + "tool outcome side_effect_state is invalid", + ) + if outcome.safe_remediation is not None and not isinstance( + outcome.safe_remediation, str + ): + raise ToolExecutionError( + "invalid_tool_outcome", + "tool outcome safe_remediation must be a string or null", + ) if not isinstance(outcome.retryable, bool) or not isinstance( outcome.metadata, dict ): @@ -522,6 +590,33 @@ def normalize_tool_outcome( nul_replacements += nul_count control_replacements += control_count error_code = error_code[:200] or None + model_action = outcome.model_action or { + "succeeded": "continue", + "failed": "choose_other_tool", + "pending": "wait", + "unknown": "reconcile", + }[outcome.status] + side_effect_state = outcome.side_effect_state or { + "succeeded": "confirmed", + "failed": "none", + "pending": "possible", + "unknown": "unknown", + }[outcome.status] + safe_remediation = outcome.safe_remediation + if safe_remediation is not None: + ( + safe_remediation, + remediation_nul, + remediation_control, + remediation_redactions, + ) = _normalize_text(safe_remediation, redact=True) + nul_replacements += remediation_nul + control_replacements += remediation_control + redaction_count += remediation_redactions + safe_remediation = _truncate_utf8( + safe_remediation.strip(), + _SAFE_REMEDIATION_MAX_BYTES, + ) or None archived_body: str | None = None summary_truncated = False @@ -545,6 +640,9 @@ def normalize_tool_outcome( **outcome.metadata, "error_code": error_code, "retryable": retryable, + "model_action": model_action, + "side_effect_state": side_effect_state, + "safe_remediation": safe_remediation, "artifact_refs": list(refs[0]), "evidence_refs": list(refs[1]), "nul_replacements": nul_replacements, @@ -568,6 +666,9 @@ def normalize_tool_outcome( result_ref=result_ref, error_code=error_code, retryable=retryable, + model_action=cast(ToolModelAction, model_action), + side_effect_state=cast(ToolSideEffectState, side_effect_state), + safe_remediation=safe_remediation, artifact_refs=refs[0], evidence_refs=refs[1], metadata=metadata, @@ -586,6 +687,9 @@ class ToolExecutionOutcome: result_ref: str | None error_code: str | None = None retryable: bool = False + model_action: ToolModelAction | None = None + side_effect_state: ToolSideEffectState | None = None + safe_remediation: str | None = None artifact_refs: tuple[str, ...] = () evidence_refs: tuple[str, ...] = () metadata: dict[str, Any] = field(default_factory=dict) @@ -865,6 +969,8 @@ def _require_exact_request( request_ref: str | None, side_effect_classification: str, retry_policy: str, + provider_call_id: str | None, + contract_version: str | None, ) -> None: expected = { "tool_name": tool_name, @@ -873,6 +979,13 @@ def _require_exact_request( "request_ref": request_ref, } mismatched = [field for field, value in expected.items() if getattr(existing, field) != value] + for identity_field, value in ( + ("provider_call_id", provider_call_id), + ("contract_version", contract_version), + ): + stored = getattr(existing, identity_field, None) + if stored is not None and stored != value: + mismatched.append(identity_field) if _execution_arguments(existing) != stored_arguments: mismatched.append("sanitized_arguments") if _execution_metadata(existing) != (side_effect_classification, retry_policy): @@ -905,6 +1018,21 @@ def _outcome(execution: AgentToolExecution) -> ToolExecutionOutcome: else None ), retryable=metadata.get("retryable") is True, + model_action=( + cast(ToolModelAction, metadata["model_action"]) + if metadata.get("model_action") in _MODEL_ACTIONS + else None + ), + side_effect_state=( + cast(ToolSideEffectState, metadata["side_effect_state"]) + if metadata.get("side_effect_state") in _SIDE_EFFECT_STATES + else None + ), + safe_remediation=( + str(metadata["safe_remediation"]) + if isinstance(metadata.get("safe_remediation"), str) + else None + ), artifact_refs=tuple( str(value) for value in artifact_refs if isinstance(value, str) ) if isinstance(artifact_refs, list) else (), @@ -1127,6 +1255,8 @@ async def reserve_tool_execution( request_ref: str | None, side_effect_classification: SideEffectClassification, retry_policy: RetryPolicy, + provider_call_id: str | None = None, + contract_version: str | None = None, lease_owner: str, lease_ttl_seconds: int, resume_safe_read: bool = False, @@ -1150,6 +1280,10 @@ async def reserve_tool_execution( lease_ttl_seconds=lease_ttl_seconds, ) arguments_hash = fingerprint_arguments(arguments) + if provider_call_id is not None: + _require_text(provider_call_id, field="provider_call_id", max_length=255) + if contract_version is not None: + _require_text(contract_version, field="contract_version", max_length=255) stored_arguments = _stored_arguments( sanitized_arguments, side_effect_classification=side_effect_classification, @@ -1176,6 +1310,8 @@ async def reserve_tool_execution( request_ref=request_ref, side_effect_classification=side_effect_classification, retry_policy=retry_policy, + provider_call_id=provider_call_id, + contract_version=contract_version, ) prior_status = existing.status decision = _decision_for_existing( @@ -1194,6 +1330,8 @@ async def reserve_tool_execution( tenant_id=tenant_id, run_id=run_id, tool_call_id=tool_call_id, + provider_call_id=provider_call_id, + contract_version=contract_version, tool_name=tool_name, assistant_message_id=assistant_message_id, arguments_hash=arguments_hash, @@ -1242,6 +1380,8 @@ async def reserve_tool_execution( request_ref=request_ref, side_effect_classification=side_effect_classification, retry_policy=retry_policy, + provider_call_id=provider_call_id, + contract_version=contract_version, ) # A concurrent winner has already crossed into started. Even when its # lease later expires, the losing worker may not execute the call. diff --git a/backend/app/services/agent_runtime/tool_registry.py b/backend/app/services/agent_runtime/tool_registry.py new file mode 100644 index 000000000..979adf55a --- /dev/null +++ b/backend/app/services/agent_runtime/tool_registry.py @@ -0,0 +1,196 @@ +"""Incremental complete-contract registry for Durable Runtime tools. + +The registry is intentionally additive. Existing typed adapters remain on the +legacy compatibility path until their whole execution contract is migrated. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from typing import cast + +from app.services.agent_runtime.state import JsonObject +from app.services.agent_runtime.tool_contracts import ( + ToolBindingKind, + ToolCancelCapability, + ToolContractError, + ToolEffect, + ToolExecutionBinding, + ToolRetryPolicy, + ToolWorksetEntry, + tool_cancel_capability, +) +from app.services.builtin_tool_definitions import ( + builtin_model_definition, + builtin_policy, + is_reserved_custom_tool_name, +) + + +def _function_contract(model_definition: Mapping[str, object]) -> tuple[str, JsonObject]: + function = model_definition.get("function") + if not isinstance(function, Mapping): + raise ToolContractError("Registered Tool requires a function definition") + name = function.get("name") + schema = function.get("parameters") + if not isinstance(name, str) or not name.strip(): + raise ToolContractError("Registered Tool requires a non-empty name") + if not isinstance(schema, Mapping): + raise ToolContractError("Registered Tool requires an object schema") + return name.strip(), cast(JsonObject, deepcopy(dict(schema))) + + +@dataclass(frozen=True, slots=True) +class RegisteredTool: + """One Tool may enter the new Workset only when every policy is explicit.""" + + model_definition: JsonObject + binding_kind: ToolBindingKind + handler_key: str + effect: ToolEffect + retry_policy: ToolRetryPolicy + authorization_policy: str + recovery_policy: str + deadline_policy: str + cancel_capability: ToolCancelCapability + contract_version: str + + def __post_init__(self) -> None: + name, schema = _function_contract(self.model_definition) + if not self.handler_key.strip(): + raise ToolContractError("Registered Tool requires a handler binding") + if not self.authorization_policy.strip(): + raise ToolContractError("Registered Tool requires authorization policy") + if not self.recovery_policy.strip(): + raise ToolContractError("Registered Tool requires recovery policy") + if not self.contract_version.strip(): + raise ToolContractError("Registered Tool requires a contract version") + if tool_cancel_capability(self.deadline_policy) != self.cancel_capability: + raise ToolContractError( + "Registered Tool cancel capability conflicts with deadline policy" + ) + # Reuse the checkpoint contract as the final completeness and size gate. + self.to_workset_entry(name=name, schema=schema) + + @property + def tool_name(self) -> str: + return _function_contract(self.model_definition)[0] + + def to_workset_entry( + self, + *, + name: str | None = None, + schema: JsonObject | None = None, + ) -> ToolWorksetEntry: + resolved_name, resolved_schema = _function_contract(self.model_definition) + return ToolWorksetEntry( + tool_name=name or resolved_name, + contract_version=self.contract_version, + parameters_schema=schema or resolved_schema, + binding=ToolExecutionBinding( + kind=self.binding_kind, + handler_key=self.handler_key, + ), + effect=self.effect, + retry_policy=self.retry_policy, + authorization_policy=self.authorization_policy, + deadline_policy=self.deadline_policy, + recovery_policy=self.recovery_policy, + ) + + +def _version(name: str, schema: Mapping[str, object], binding_kind: str) -> str: + encoded = json.dumps( + {"name": name, "schema": schema, "binding_kind": binding_kind}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return f"registered:{name}:{hashlib.sha256(encoded).hexdigest()[:16]}" + + +def _registered_builtin(name: str, *, binding_kind: ToolBindingKind) -> RegisteredTool: + definition = builtin_model_definition(name) + if definition is None: # pragma: no cover - import-time invariant + raise ToolContractError(f"Registered builtin {name!r} has no model definition") + function = cast(JsonObject, deepcopy(definition)) + _, schema = _function_contract(function) + policy = builtin_policy(name) + deadline_policy = ( + "agentbay_read" if name == "agentbay_code_read_file" else "runtime_default" + ) + return RegisteredTool( + model_definition=function, + binding_kind=binding_kind, + handler_key=name, + effect=cast(ToolEffect, policy["effect"]), + retry_policy=cast(ToolRetryPolicy, policy["retry_policy"]), + authorization_policy="runtime_default", + recovery_policy="runtime_default", + deadline_policy=deadline_policy, + cancel_capability=tool_cancel_capability(deadline_policy), + contract_version=_version(name, schema, binding_kind), + ) + + +_STATIC_REGISTRY = { + "read_file": _registered_builtin("read_file", binding_kind="builtin"), + "agentbay_code_read_file": _registered_builtin( + "agentbay_code_read_file", + binding_kind="agentbay", + ), +} +STATIC_REGISTERED_TOOL_NAMES = frozenset(_STATIC_REGISTRY) + + +def registered_tool(name: str) -> RegisteredTool | None: + return _STATIC_REGISTRY.get(name) + + +def registered_dynamic_mcp(model_definition: Mapping[str, object]) -> RegisteredTool: + name, schema = _function_contract(model_definition) + definition = cast(JsonObject, deepcopy(dict(model_definition))) + return RegisteredTool( + model_definition=definition, + binding_kind="mcp", + handler_key=name, + effect="external_write", + retry_policy="never", + authorization_policy="runtime_default", + recovery_policy="mcp_receipt_or_reconcile", + deadline_policy="runtime_default", + cancel_capability="stop_waiting_only", + contract_version=_version(name, schema, "mcp"), + ) + + +def resolve_registered_tool( + model_definition: Mapping[str, object], + *, + dynamic_mcp_names: set[str] | frozenset[str] = frozenset(), +) -> RegisteredTool | None: + """Resolve only exact complete contracts; malformed candidates stay hidden.""" + try: + name, schema = _function_contract(model_definition) + static = registered_tool(name) + if static is not None: + static_schema = static.to_workset_entry().parameters_schema + return static if schema == static_schema else None + if name in dynamic_mcp_names and not is_reserved_custom_tool_name(name): + return registered_dynamic_mcp(model_definition) + except ToolContractError: + return None + return None + + +__all__ = [ + "STATIC_REGISTERED_TOOL_NAMES", + "RegisteredTool", + "registered_dynamic_mcp", + "registered_tool", + "resolve_registered_tool", +] diff --git a/backend/app/services/agent_runtime/tool_repair_budget.py b/backend/app/services/agent_runtime/tool_repair_budget.py new file mode 100644 index 000000000..9077371b7 --- /dev/null +++ b/backend/app/services/agent_runtime/tool_repair_budget.py @@ -0,0 +1,200 @@ +"""Checkpoint-safe Tool repair episode transitions.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass + +from app.services.agent_runtime.state import JsonObject + +SAME_FINGERPRINT_FAILURE_LIMIT = 10 +TOOL_EPISODE_FAILURE_LIMIT = 10 +_REPAIRABLE_MODEL_ACTIONS = frozenset( + {"repair_arguments", "choose_other_tool"} +) + + +class ToolRepairBudgetError(ValueError): + """Checkpoint repair episode state is malformed.""" + + +@dataclass(frozen=True, slots=True) +class ToolRepairTransition: + episodes: JsonObject + counted: bool = False + reset_tool_name: str | None = None + pause_reason: str | None = None + paused_tool_name: str | None = None + + +def _text(value: object, *, field: str, max_length: int = 255) -> str: + if not isinstance(value, str) or not value.strip(): + raise ToolRepairBudgetError(f"{field} must be non-empty text") + normalized = value.strip() + if len(normalized) > max_length: + raise ToolRepairBudgetError(f"{field} exceeds its length limit") + return normalized + + +def _parse_episodes(raw: object) -> dict[str, dict]: + if raw in (None, {}): + return {} + if not isinstance(raw, Mapping) or raw.get("version") != 1: + raise ToolRepairBudgetError("tool repair episodes require version 1") + by_tool = raw.get("by_tool") + if not isinstance(by_tool, Mapping) or len(by_tool) > 256: + raise ToolRepairBudgetError("tool repair episodes by_tool is invalid") + parsed: dict[str, dict] = {} + for raw_tool_name, raw_episode in by_tool.items(): + tool_name = _text(raw_tool_name, field="tool_name", max_length=200) + if not isinstance(raw_episode, Mapping): + raise ToolRepairBudgetError("tool repair episode must be an object") + episode = dict(raw_episode) + for field in ("total_failures", "same_fingerprint_failures"): + value = episode.get(field) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ToolRepairBudgetError(f"repair episode {field} is invalid") + _text(episode.get("episode_id"), field="episode_id") + _text(episode.get("last_fingerprint"), field="last_fingerprint") + _text( + episode.get("last_call_instance_id"), + field="last_call_instance_id", + ) + updated_at = episode.get("updated_at_model_step") + if ( + isinstance(updated_at, bool) + or not isinstance(updated_at, int) + or updated_at < 0 + ): + raise ToolRepairBudgetError( + "repair episode updated_at_model_step is invalid" + ) + parsed[tool_name] = episode + return parsed + + +def _json(by_tool: Mapping[str, dict]) -> JsonObject: + return { + "version": 1, + "by_tool": { + tool_name: dict(episode) + for tool_name, episode in sorted(by_tool.items()) + }, + } + + +def _fingerprint(message: Mapping[str, object]) -> str: + explicit = message.get("failure_fingerprint") + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip()[:255] + payload = { + "error_code": message.get("error_code"), + "model_action": message.get("model_action"), + "content": str(message.get("content") or "")[:2000], + } + digest = hashlib.sha256( + json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + return f"sha256:{digest}" + + +def apply_tool_result( + raw_episodes: object, + message: Mapping[str, object], + *, + model_step: int, +) -> ToolRepairTransition: + """Apply one model-visible Tool Result without touching other budgets.""" + if isinstance(model_step, bool) or not isinstance(model_step, int) or model_step < 0: + raise ToolRepairBudgetError("model_step must be a non-negative integer") + by_tool = _parse_episodes(raw_episodes) + tool_name = message.get("name") + if not isinstance(tool_name, str) or not tool_name.strip(): + return ToolRepairTransition(episodes=_json(by_tool)) + tool_name = tool_name.strip() + status = message.get("execution_status") + if status == "succeeded": + reset = by_tool.pop(tool_name, None) is not None + return ToolRepairTransition( + episodes=_json(by_tool), + reset_tool_name=tool_name if reset else None, + ) + if ( + status != "failed" + or message.get("model_action") not in _REPAIRABLE_MODEL_ACTIONS + or message.get("side_effect_state") != "none" + ): + return ToolRepairTransition(episodes=_json(by_tool)) + + call_instance_id = _text( + message.get("tool_call_id") or message.get("call_instance_id"), + field="call_instance_id", + ) + fingerprint = _fingerprint(message) + prior = by_tool.get(tool_name) + total_failures = int(prior["total_failures"]) + 1 if prior else 1 + same_failures = ( + int(prior["same_fingerprint_failures"]) + 1 + if prior and prior["last_fingerprint"] == fingerprint + else 1 + ) + episode_id = ( + str(prior["episode_id"]) + if prior + else "episode:" + + hashlib.sha256( + f"{tool_name}:{call_instance_id}".encode() + ).hexdigest()[:24] + ) + by_tool[tool_name] = { + "tool_name": tool_name, + "episode_id": episode_id, + "total_failures": total_failures, + "last_fingerprint": fingerprint, + "same_fingerprint_failures": same_failures, + "last_call_instance_id": call_instance_id, + "updated_at_model_step": model_step, + } + pause_reason = ( + "tool_repair_same_fingerprint_limit_reached" + if same_failures >= SAME_FINGERPRINT_FAILURE_LIMIT + else "tool_repair_episode_limit_reached" + if total_failures >= TOOL_EPISODE_FAILURE_LIMIT + else None + ) + return ToolRepairTransition( + episodes=_json(by_tool), + counted=True, + pause_reason=pause_reason, + paused_tool_name=tool_name if pause_reason is not None else None, + ) + + +def reset_tool_repair_episodes( + raw_episodes: object, + *, + tool_name: str | None = None, +) -> JsonObject: + by_tool = _parse_episodes(raw_episodes) + if tool_name is None: + by_tool.clear() + else: + by_tool.pop(_text(tool_name, field="tool_name", max_length=200), None) + return _json(by_tool) + + +__all__ = [ + "SAME_FINGERPRINT_FAILURE_LIMIT", + "TOOL_EPISODE_FAILURE_LIMIT", + "ToolRepairBudgetError", + "ToolRepairTransition", + "apply_tool_result", + "reset_tool_repair_episodes", +] diff --git a/backend/app/services/agent_runtime/tool_step_service.py b/backend/app/services/agent_runtime/tool_step_service.py index 75e64ec10..030e9ab6f 100644 --- a/backend/app/services/agent_runtime/tool_step_service.py +++ b/backend/app/services/agent_runtime/tool_step_service.py @@ -2,14 +2,17 @@ from __future__ import annotations +import asyncio +import hashlib +import json +import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence +from copy import deepcopy from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta -import hashlib -import json from typing import Protocol, cast -import uuid +from loguru import logger from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert @@ -21,7 +24,14 @@ RuntimeA2AService, a2a_waiting_request, ) +from app.services.agent_runtime.cancel_source import RuntimeToolCancelToken from app.services.agent_runtime.command_worker import RuntimeSessionFactory +from app.services.agent_runtime.group_at import ( + AT_TOOL_NAME, + GroupAtArgumentsError, + group_at_tool_definition, + parse_group_at_participant_ids, +) from app.services.agent_runtime.group_runtime_tools import ( GROUP_DELETE_WORKSPACE_FILE, GROUP_READ_TOOL_NAMES, @@ -36,12 +46,8 @@ GroupWorkspaceReconciliationPending, with_group_runtime_tools, ) -from app.services.agent_runtime.group_at import ( - AT_TOOL_NAME, - GroupAtArgumentsError, - parse_group_at_participant_ids, -) from app.services.agent_runtime.node_executor import ( + CancelSignal, RuntimeCancelSource, ToolStepResult, ) @@ -51,13 +57,29 @@ RuntimeGraphState, runtime_messages_as_json, ) +from app.services.agent_runtime.tool_contracts import ( + AcceptedToolCall, + StepToolContext, + ToolBindingKind, + ToolContractError, + ToolEffect, + ToolExecutionBinding, + ToolRetryPolicy, + ToolWorksetEntry, + deadline_policy_for_tool, + parse_step_tool_context, + resolve_tool_deadline_seconds, + tool_cancel_capability, + workset_version, +) from app.services.agent_runtime.tool_execution import ( - RetryableToolNodeError, SAFE_READ_MAX_ATTEMPTS, + RetryableToolNodeError, ToolExecutionError, ToolExecutionOutcome, ToolExecutionReconciliationPending, ToolExecutionReservation, + assert_tool_execution_fence, execution_outcome, mark_expired_safe_read_result_unavailable, mark_tool_execution_async_pending, @@ -66,6 +88,7 @@ mark_tool_execution_succeeded, mark_tool_execution_unknown, normalize_tool_outcome, + renew_tool_execution_lease, reserve_tool_execution, sanitize_tool_arguments, settle_async_operation_executions, @@ -75,17 +98,23 @@ ToolResultReconciler, ToolResultStore, ) -from app.services.autonomy_service import autonomy_service +from app.services.agent_runtime.tool_validation import ( + ToolValidationContractError, + validate_tool_arguments, +) from app.services.agent_tools import ( agentbay_run_scope_id, execute_builtin_tool_outcome, get_runtime_agent_tools_for_llm, ) +from app.services.autonomy_service import autonomy_service from app.services.builtin_tool_definitions import ( + BUILTIN_TOOL_NAMES, builtin_cross_space_action, builtin_policy, builtin_sensitive_paths, ) + _CONTROL_TOOL_NAMES = frozenset({"finish", "wait"}) _HEARTBEAT_PRIVATE_PLAZA_TOOLS = frozenset( {"plaza_get_new_posts", "plaza_create_post", "plaza_add_comment"} @@ -94,6 +123,26 @@ "plaza_create_post": 1, "plaza_add_comment": 2, } +LEGACY_TOOL_CONTEXT_DELETE_GATE = ( + "zero legacy pending batches observed for one full supported release, " + "with the rollback window closed" +) + + +def legacy_tool_context_deletion_ready( + *, + observed_legacy_batches: int, + full_supported_release_elapsed: bool, + rollback_window_closed: bool, +) -> bool: + """Make compatibility removal an explicit, testable release gate.""" + if observed_legacy_batches < 0: + raise ValueError("observed_legacy_batches cannot be negative") + return ( + observed_legacy_batches == 0 + and full_supported_release_elapsed + and rollback_window_closed + ) async def _insert_runtime_activity( @@ -155,6 +204,115 @@ def _policy(tool_name: str) -> ToolPolicy: return ToolPolicy(policy["effect"], policy["retry_policy"]) +def _accepted_call( + context: StepToolContext, + *, + call_id: str, + tool_name: str, +) -> AcceptedToolCall: + try: + accepted = context.accepted_call(call_id) + except ToolContractError as exc: + raise ToolExecutionError("tool_context_corrupt", str(exc)) from exc + if accepted.entry.tool_name != tool_name: + raise ToolExecutionError( + "tool_context_corrupt", + "pending Tool Call name does not match its accepted execution binding", + ) + return accepted + + +def _legacy_step_tool_context( + state: RuntimeGraphState, + *, + assistant_message_id: str, + tools: Sequence[Mapping[str, object]], +) -> StepToolContext: + """Resolve one old pending batch once and make later Tool nodes stable.""" + entries: list[ToolWorksetEntry] = [] + for tool in tools: + name = _tool_name(tool) + function = tool.get("function") + if name is None or not isinstance(function, Mapping): + continue + raw_schema = function.get("parameters", {"type": "object", "properties": {}}) + if not isinstance(raw_schema, Mapping): + raise ToolExecutionError( + "legacy_tool_context_unavailable", + f"legacy Tool {name!r} has no valid parameters schema", + ) + policy = _policy(name) + binding_kind = ( + "group" + if name in GROUP_TOOL_NAMES or name == AT_TOOL_NAME + else "a2a" + if name == "send_message_to_agent" + else "agentbay" + if name.startswith("agentbay_") + else "builtin" + if name in BUILTIN_TOOL_NAMES + else "legacy" + ) + schema = cast(JsonObject, deepcopy(dict(raw_schema))) + digest = hashlib.sha256( + json.dumps( + {"name": name, "schema": schema, "binding_kind": binding_kind}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest()[:16] + entries.append( + ToolWorksetEntry( + tool_name=name, + contract_version=f"legacy:{name}:{digest}", + parameters_schema=schema, + binding=ToolExecutionBinding( + kind=cast(ToolBindingKind, binding_kind), + handler_key=name, + ), + effect=cast(ToolEffect, policy.side_effect_classification), + retry_policy=cast(ToolRetryPolicy, policy.retry_policy), + deadline_policy=deadline_policy_for_tool(name).name, + ) + ) + entries_by_name = {entry.tool_name: entry for entry in entries} + raw_pending = state["lifecycle"].get("pending_tool_calls", []) + if not isinstance(raw_pending, list) or not raw_pending: + raise ToolExecutionError( + "legacy_tool_context_unavailable", + "legacy checkpoint has no pending Tool batch", + ) + accepted_calls: list[AcceptedToolCall] = [] + for raw_call in raw_pending: + if not isinstance(raw_call, Mapping): + raise ToolExecutionError( + "legacy_tool_context_unavailable", + "legacy pending Tool batch contains an invalid call", + ) + call_id, tool_name, _arguments = _call_fields(cast(JsonObject, dict(raw_call))) + entry = entries_by_name.get(tool_name) + if entry is None: + raise ToolExecutionError( + "tool_not_enabled", + f"tool {tool_name!r} is not enabled for this Agent", + ) + accepted_calls.append( + AcceptedToolCall( + call_instance_id=call_id, + provider_call_id=call_id, + entry=entry, + ) + ) + return StepToolContext( + assistant_message_id=assistant_message_id, + model_step=max(1, int(state["lifecycle"].get("model_step_count", 0))), + workset_version=workset_version(tuple(entries)), + accepted_calls=tuple(accepted_calls), + legacy_resolved=True, + ) + + def _tool_name(tool: Mapping[str, object]) -> str | None: function = tool.get("function") if not isinstance(function, Mapping): @@ -277,6 +435,20 @@ def _result_message( "content": content, "execution_status": outcome.status, "result_ref": outcome.result_ref, + "model_action": outcome.model_action + or { + "succeeded": "continue", + "failed": "choose_other_tool", + "pending": "wait", + "unknown": "reconcile", + }[outcome.status], + "side_effect_state": outcome.side_effect_state + or { + "succeeded": "confirmed", + "failed": "none", + "pending": "possible", + "unknown": "unknown", + }[outcome.status], } if outcome.error_code is not None: message["error_code"] = outcome.error_code @@ -286,6 +458,17 @@ def _result_message( message["artifact_refs"] = list(outcome.artifact_refs) if outcome.evidence_refs: message["evidence_refs"] = list(outcome.evidence_refs) + if outcome.safe_remediation is not None: + message["safe_remediation"] = outcome.safe_remediation + for field in ( + "execution_id", + "call_instance_id", + "provider_call_id", + "contract_version", + ): + value = outcome.metadata.get(field) + if isinstance(value, str) and value: + message[field] = value[:255] return message @@ -656,79 +839,82 @@ async def _reserve( assistant_message_id: str, arguments: dict, policy: ToolPolicy, + provider_call_id: str | None = None, + contract_version: str | None = None, lease_owner: str, reasoning_content: str = "", assistant_content: str = "", ) -> ToolExecutionReservation: - async with self._session_factory() as db: - async with db.begin(): - reservation = await reserve_tool_execution( + async with self._session_factory() as db, db.begin(): + reservation = await reserve_tool_execution( + db, + tenant_id=tenant_id, + run_id=run_id, + tool_call_id=call_id, + tool_name=tool_name, + assistant_message_id=assistant_message_id, + arguments=arguments, + sanitized_arguments=sanitize_tool_arguments( + arguments, + sensitive_paths=builtin_sensitive_paths(tool_name), + ), + request_ref=None, + side_effect_classification=cast(str, policy.side_effect_classification), # type: ignore[arg-type] + retry_policy=cast(str, policy.retry_policy), # type: ignore[arg-type] + provider_call_id=provider_call_id, + contract_version=contract_version, + lease_owner=lease_owner, + lease_ttl_seconds=self._lease_ttl_seconds, + resume_safe_read=( + policy.side_effect_classification == "read" + and policy.retry_policy == "safe" + ), + ) + if reasoning_content.strip(): + await _insert_runtime_activity( db, tenant_id=tenant_id, run_id=run_id, - tool_call_id=call_id, - tool_name=tool_name, - assistant_message_id=assistant_message_id, - arguments=arguments, - sanitized_arguments=sanitize_tool_arguments( - arguments, - sensitive_paths=builtin_sensitive_paths(tool_name), - ), - request_ref=None, - side_effect_classification=cast(str, policy.side_effect_classification), # type: ignore[arg-type] - retry_policy=cast(str, policy.retry_policy), # type: ignore[arg-type] - lease_owner=lease_owner, - lease_ttl_seconds=self._lease_ttl_seconds, - resume_safe_read=( - policy.side_effect_classification == "read" - and policy.retry_policy == "safe" - ), + key=f"activity:thinking:{assistant_message_id}", + summary="Runtime model reasoning available", + payload={ + "status": "running", + "activity_type": "thinking", + "content": reasoning_content.strip(), + "message_id": assistant_message_id, + }, ) - if reasoning_content.strip(): - await _insert_runtime_activity( - db, - tenant_id=tenant_id, - run_id=run_id, - key=f"activity:thinking:{assistant_message_id}", - summary="Runtime model reasoning available", - payload={ - "status": "running", - "activity_type": "thinking", - "content": reasoning_content.strip(), - "message_id": assistant_message_id, - }, - ) - if assistant_content.strip(): - await _insert_runtime_activity( - db, - tenant_id=tenant_id, - run_id=run_id, - key=f"activity:progress:{assistant_message_id}", - summary="Runtime model progress available", - payload={ - "status": "running", - "activity_type": "assistant_progress", - "content": assistant_content.strip(), - "message_id": assistant_message_id, - }, - ) + if assistant_content.strip(): await _insert_runtime_activity( db, tenant_id=tenant_id, run_id=run_id, - key=f"activity:tool:{call_id}:running", - summary=f"Runtime tool {tool_name} started", + key=f"activity:progress:{assistant_message_id}", + summary="Runtime model progress available", payload={ "status": "running", - "activity_type": "tool_call", - "call_id": call_id, - "name": tool_name, - "args": dict(reservation.execution.sanitized_arguments or {}), - "reasoning_content": reasoning_content.strip(), - "assistant_message_id": assistant_message_id, + "activity_type": "assistant_progress", + "content": assistant_content.strip(), + "message_id": assistant_message_id, }, ) - return reservation + await _insert_runtime_activity( + db, + tenant_id=tenant_id, + run_id=run_id, + key=f"activity:tool:{call_id}:running", + summary=f"Runtime tool {tool_name} started", + payload={ + "status": "running", + "activity_type": "tool_call", + "call_id": call_id, + "name": tool_name, + "args": dict(reservation.execution.sanitized_arguments or {}), + "reasoning_content": reasoning_content.strip(), + "assistant_message_id": assistant_message_id, + }, + ) + return reservation async def _settle_outcome( self, @@ -866,6 +1052,10 @@ async def _settle_outcome( metadata={ **normalized.metadata, "runtime_attempt_count": attempt_count, + "execution_id": str(reservation.execution.id), + "call_instance_id": reservation.execution.tool_call_id, + "provider_call_id": reservation.execution.provider_call_id, + "contract_version": reservation.execution.contract_version, }, ) if normalized.status == "pending": @@ -877,32 +1067,31 @@ async def _settle_outcome( metadata=normalized.metadata, ), ) - async with self._session_factory() as db: - async with db.begin(): - execution = await mark_tool_execution_async_pending( - db, - tenant_id=tenant_id, - execution_id=reservation.execution.id, - lease_owner=lease_owner, - result_summary=normalized.result_summary, - metadata=normalized.metadata, - ) - await _insert_runtime_activity( - db, - tenant_id=tenant_id, - run_id=reservation.execution.run_id, - key=f"activity:tool:{reservation.execution.tool_call_id}:pending", - summary=f"Runtime tool {reservation.execution.tool_name} pending", - payload={ - "status": "running", - "activity_type": "tool_call", - "call_id": reservation.execution.tool_call_id, - "name": reservation.execution.tool_name, - "args": dict(reservation.execution.sanitized_arguments or {}), - "result": execution.result_summary or "", - "execution_status": "pending", - }, - ) + async with self._session_factory() as db, db.begin(): + execution = await mark_tool_execution_async_pending( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + result_summary=normalized.result_summary, + metadata=normalized.metadata, + ) + await _insert_runtime_activity( + db, + tenant_id=tenant_id, + run_id=reservation.execution.run_id, + key=f"activity:tool:{reservation.execution.tool_call_id}:pending", + summary=f"Runtime tool {reservation.execution.tool_name} pending", + payload={ + "status": "running", + "activity_type": "tool_call", + "call_id": reservation.execution.tool_call_id, + "name": reservation.execution.tool_name, + "args": dict(reservation.execution.sanitized_arguments or {}), + "result": execution.result_summary or "", + "execution_status": "pending", + }, + ) return replace( normalized, result_summary=execution.result_summary, @@ -914,17 +1103,16 @@ async def _settle_outcome( ), ) if normalized.retryable and attempt_count < SAFE_READ_MAX_ATTEMPTS: - async with self._session_factory() as db: - async with db.begin(): - await mark_tool_execution_retry_pending( - db, - tenant_id=tenant_id, - execution_id=reservation.execution.id, - lease_owner=lease_owner, - result_summary=normalized.result_summary, - error_code=normalized.error_code, - metadata=normalized.metadata, - ) + async with self._session_factory() as db, db.begin(): + await mark_tool_execution_retry_pending( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + result_summary=normalized.result_summary, + error_code=normalized.error_code, + metadata=normalized.metadata, + ) raise RetryableToolNodeError( tool_call_id=reservation.execution.tool_call_id, error_code=normalized.error_code, @@ -952,80 +1140,301 @@ async def _settle_outcome( }, ) - async with self._session_factory() as db: - async with db.begin(): - operation = normalized.metadata.get("async_operation") - terminal_async = ( - normalized.status in {"succeeded", "failed", "unknown"} - and normalized.metadata.get("runtime_async_pending") is False - and isinstance(operation, Mapping) - and isinstance(operation.get("operation_key"), str) - and bool(operation.get("operation_key")) - ) - if terminal_async: - execution = await settle_async_operation_executions( - db, - tenant_id=tenant_id, - run_id=reservation.execution.run_id, - execution_id=reservation.execution.id, - lease_owner=lease_owner, - status=normalized.status, - result_summary=normalized.result_summary, - result_ref=normalized.result_ref, - error_code=normalized.error_code, - retryable=normalized.retryable, - artifact_refs=normalized.artifact_refs, - evidence_refs=normalized.evidence_refs, - metadata=normalized.metadata, - ) - else: - settle = { - "succeeded": mark_tool_execution_succeeded, - "failed": mark_tool_execution_failed, - "unknown": mark_tool_execution_unknown, - }[normalized.status] - execution = await settle( - db, - tenant_id=tenant_id, - execution_id=reservation.execution.id, - lease_owner=lease_owner, - result_summary=normalized.result_summary, - result_ref=normalized.result_ref, - error_code=normalized.error_code, - retryable=normalized.retryable, - artifact_refs=normalized.artifact_refs, - evidence_refs=normalized.evidence_refs, - metadata=normalized.metadata, - ) - await _insert_runtime_activity( + async with self._session_factory() as db, db.begin(): + operation = normalized.metadata.get("async_operation") + terminal_async = ( + normalized.status in {"succeeded", "failed", "unknown"} + and normalized.metadata.get("runtime_async_pending") is False + and isinstance(operation, Mapping) + and isinstance(operation.get("operation_key"), str) + and bool(operation.get("operation_key")) + ) + if terminal_async: + execution = await settle_async_operation_executions( db, tenant_id=tenant_id, run_id=reservation.execution.run_id, - key=( - f"activity:tool:{reservation.execution.tool_call_id}:" - f"{normalized.status}" - ), - summary=( - f"Runtime tool {reservation.execution.tool_name} " - f"{normalized.status}" - ), - payload={ - "status": "done", - "activity_type": "tool_call", - "call_id": reservation.execution.tool_call_id, - "name": reservation.execution.tool_name, - "args": dict(reservation.execution.sanitized_arguments or {}), - "result": execution.result_summary or "", - "execution_status": normalized.status, - "error_code": normalized.error_code, - }, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + status=normalized.status, + result_summary=normalized.result_summary, + result_ref=normalized.result_ref, + error_code=normalized.error_code, + retryable=normalized.retryable, + artifact_refs=normalized.artifact_refs, + evidence_refs=normalized.evidence_refs, + metadata=normalized.metadata, + ) + else: + settle = { + "succeeded": mark_tool_execution_succeeded, + "failed": mark_tool_execution_failed, + "unknown": mark_tool_execution_unknown, + }[normalized.status] + execution = await settle( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + result_summary=normalized.result_summary, + result_ref=normalized.result_ref, + error_code=normalized.error_code, + retryable=normalized.retryable, + artifact_refs=normalized.artifact_refs, + evidence_refs=normalized.evidence_refs, + metadata=normalized.metadata, ) + await _insert_runtime_activity( + db, + tenant_id=tenant_id, + run_id=reservation.execution.run_id, + key=( + f"activity:tool:{reservation.execution.tool_call_id}:" + f"{normalized.status}" + ), + summary=( + f"Runtime tool {reservation.execution.tool_name} " + f"{normalized.status}" + ), + payload={ + "status": "done", + "activity_type": "tool_call", + "call_id": reservation.execution.tool_call_id, + "name": reservation.execution.tool_name, + "args": dict(reservation.execution.sanitized_arguments or {}), + "result": execution.result_summary or "", + "execution_status": normalized.status, + "error_code": normalized.error_code, + }, + ) return replace( normalized, result_summary=execution.result_summary, result_ref=execution.result_ref, ) + async def _renew_execution_lease( + self, + *, + tenant_id: uuid.UUID, + reservation: ToolExecutionReservation, + lease_owner: str, + ) -> None: + async with self._session_factory() as db, db.begin(): + await renew_tool_execution_lease( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + lease_ttl_seconds=self._lease_ttl_seconds, + ) + + async def _assert_execution_fence( + self, + *, + tenant_id: uuid.UUID, + reservation: ToolExecutionReservation, + lease_owner: str, + ) -> None: + # Historical fixtures/rows created before lease fencing may not carry + # an expiry. A fresh executable reservation always does; preserve the + # legacy compatibility path without pretending it is fenced. + if reservation.execution.lease_expires_at is None: + return + async with self._session_factory() as db, db.begin(): + await assert_tool_execution_fence( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + ) + + async def _lease_renewal_loop( + self, + *, + tenant_id: uuid.UUID, + reservation: ToolExecutionReservation, + lease_owner: str, + ) -> None: + interval = max(0.05, min(30.0, self._lease_ttl_seconds / 3)) + while True: + await asyncio.sleep(interval) + await self._renew_execution_lease( + tenant_id=tenant_id, + reservation=reservation, + lease_owner=lease_owner, + ) + + async def _wait_for_tool_cancel( + self, + token: RuntimeToolCancelToken, + ) -> CancelSignal: + while True: + signal = await token.poll() + if signal is not None: + return signal + await asyncio.sleep(0.25) + + async def _execute_application_with_controls( + self, + *, + state: RuntimeGraphState, + context: RuntimeContext, + tenant_id: uuid.UUID, + agent: Agent, + accepted: AcceptedToolCall, + arguments: dict, + reservation: ToolExecutionReservation, + lease_owner: str, + ) -> tuple[ToolExecutionOutcome | str, CancelSignal | None]: + """Run one application adapter under independent deadline/cancel/lease controls.""" + policy_name = accepted.entry.deadline_policy + try: + deadline_seconds = resolve_tool_deadline_seconds( + policy_name, + arguments.get("timeout"), + ) + cancel_capability = tool_cancel_capability(policy_name) + except ToolContractError as exc: + raise ToolExecutionError("tool_context_corrupt", str(exc)) from exc + + await self._assert_execution_fence( + tenant_id=tenant_id, + reservation=reservation, + lease_owner=lease_owner, + ) + cancel_token = RuntimeToolCancelToken( + source=self._cancel_source, + state=state, + context=context, + capability=cancel_capability, + ) + agentbay_run_token = None + if accepted.entry.tool_name.startswith("agentbay_"): + agentbay_run_token = agentbay_run_scope_id.set(context.run_id) + try: + operation_task = asyncio.create_task( + self._tool_executor( + accepted.entry.tool_name, + arguments, + agent.id, + ( + uuid.UUID(context.actor_user_id) + if context.actor_user_id + else agent.creator_id + ), + context.session_id or "", + ) + ) + finally: + if agentbay_run_token is not None: + agentbay_run_scope_id.reset(agentbay_run_token) + cancel_task = asyncio.create_task(self._wait_for_tool_cancel(cancel_token)) + lease_task = asyncio.create_task( + self._lease_renewal_loop( + tenant_id=tenant_id, + reservation=reservation, + lease_owner=lease_owner, + ) + ) + signal: CancelSignal | None = None + try: + done, _pending = await asyncio.wait( + {operation_task, cancel_task, lease_task}, + timeout=deadline_seconds, + return_when=asyncio.FIRST_COMPLETED, + ) + if lease_task in done: + await lease_task + raise AssertionError("lease renewal loop exited unexpectedly") + if cancel_task in done: + signal = await cancel_task + if operation_task in done: + result = await operation_task + else: + operation_task.cancel() + await asyncio.gather(operation_task, return_exceptions=True) + status = ( + "failed" + if accepted.entry.effect == "read" + else "unknown" + ) + result = ToolExecutionOutcome( + status=status, + result_summary=( + "Tool execution stopped after durable Run cancellation." + if status == "failed" + else "Tool execution was cancelled after a possible write; reconcile before retrying." + ), + result_ref=None, + error_code=( + "tool_cancelled" + if status == "failed" + else "tool_cancelled_outcome_unknown" + ), + retryable=False, + model_action=( + "wait" if status == "failed" else "reconcile" + ), + side_effect_state=( + "none" if status == "failed" else "unknown" + ), + metadata={ + **cancel_token.telemetry(signal), + "deadline_policy": policy_name, + "deadline_seconds": deadline_seconds, + }, + ) + elif operation_task in done: + result = await operation_task + else: + operation_task.cancel() + await asyncio.gather(operation_task, return_exceptions=True) + status = ( + "failed" + if accepted.entry.effect == "read" + else "unknown" + ) + result = ToolExecutionOutcome( + status=status, + result_summary=( + f"Tool read exceeded its {deadline_seconds:g}s operation deadline." + if status == "failed" + else "Tool deadline elapsed after a possible write; reconcile before retrying." + ), + result_ref=None, + error_code=( + "tool_deadline_exceeded" + if status == "failed" + else "tool_deadline_outcome_unknown" + ), + retryable=False, + model_action=( + "choose_other_tool" if status == "failed" else "reconcile" + ), + side_effect_state="none" if status == "failed" else "unknown", + metadata={ + "deadline_policy": policy_name, + "deadline_seconds": deadline_seconds, + "deadline_exceeded": True, + "cancel_capability": cancel_capability, + }, + ) + await self._assert_execution_fence( + tenant_id=tenant_id, + reservation=reservation, + lease_owner=lease_owner, + ) + return result, signal + finally: + cancel_task.cancel() + lease_task.cancel() + await asyncio.gather( + cancel_task, + lease_task, + return_exceptions=True, + ) + async def _takeover_for_reconciliation( self, *, @@ -1033,15 +1442,14 @@ async def _takeover_for_reconciliation( reservation: ToolExecutionReservation, lease_owner: str, ): - async with self._session_factory() as db: - async with db.begin(): - return await takeover_tool_execution_for_reconciliation( - db, - tenant_id=tenant_id, - execution_id=reservation.execution.id, - lease_owner=lease_owner, - lease_ttl_seconds=self._lease_ttl_seconds, - ) + async with self._session_factory() as db, db.begin(): + return await takeover_tool_execution_for_reconciliation( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + lease_owner=lease_owner, + lease_ttl_seconds=self._lease_ttl_seconds, + ) async def _mark_exception( self, @@ -1181,14 +1589,13 @@ async def _delete_autonomy_gate( if details is None: return None, None try: - async with self._session_factory() as db: - async with db.begin(): - decision = await autonomy_service.check_and_enforce( - db, - agent, - "delete_files", - details, - ) + async with self._session_factory() as db, db.begin(): + decision = await autonomy_service.check_and_enforce( + db, + agent, + "delete_files", + details, + ) except Exception as exc: return ( ToolExecutionOutcome( @@ -1270,6 +1677,7 @@ async def execute_pending( context: RuntimeContext, tool_calls: tuple[JsonObject, ...], ) -> ToolStepResult: + step_context_update: JsonObject | None = None try: tenant_id = uuid.UUID(context.tenant_id) run_id = uuid.UUID(context.run_id) @@ -1293,16 +1701,63 @@ async def execute_pending( if isinstance(assistant_message, Mapping) else "" ) - allowed_names = _allowed_tool_names( - with_group_runtime_tools( + try: + step_context = parse_step_tool_context( + state["lifecycle"].get("step_tool_context"), + allow_legacy_missing=True, + ) + except ToolContractError as exc: + raise ToolExecutionError("tool_context_corrupt", str(exc)) from exc + if ( + step_context is not None + and step_context.assistant_message_id != assistant_message_id + ): + raise ToolExecutionError( + "tool_context_corrupt", + "Step Tool Context does not match the pending Assistant message", + ) + if step_context is None: + legacy_tools = with_group_runtime_tools( await self._tool_provider(agent.id), state, ) + if AT_TOOL_NAME not in _allowed_tool_names(legacy_tools): + legacy_tools.append(group_at_tool_definition()) + if _is_group_agent_run(state): + # Historical checkpoints may still contain hidden legacy calls. + # Keep them executable without exposing the names to new model turns. + known_names = _allowed_tool_names(legacy_tools) + for name in GROUP_SCOPED_WORKSPACE_TOOL_NAMES - known_names: + legacy_tools.append( + { + "type": "function", + "function": { + "name": name, + "parameters": { + "type": "object", + "properties": {}, + }, + }, + } + ) + step_context = _legacy_step_tool_context( + state, + assistant_message_id=assistant_message_id, + tools=legacy_tools, + ) + step_context_update = step_context.to_json() + logger.warning( + "[RuntimeToolCompatibility] event=legacy_tool_context_resolved " + "run_id={} assistant_message_id={} accepted_call_count={} " + "delete_gate={!r}", + context.run_id, + assistant_message_id, + len(step_context.accepted_calls), + LEGACY_TOOL_CONTEXT_DELETE_GATE, + ) + allowed_names = frozenset( + call.entry.tool_name for call in step_context.accepted_calls ) - if _is_group_agent_run(state): - # Historical checkpoints may still contain hidden legacy calls. - # Keep them executable without exposing the names to new model turns. - allowed_names = allowed_names | GROUP_SCOPED_WORKSPACE_TOOL_NAMES messages: list[JsonObject] = [] pending_group_at_changed = False pending_group_at: JsonObject | None = None @@ -1312,8 +1767,58 @@ async def execute_pending( return ToolStepResult( messages=tuple(messages), cancel_signal=cancel, + step_tool_context=step_context_update, ) call_id, tool_name, arguments = _call_fields(call) + accepted = ( + _accepted_call( + step_context, + call_id=call_id, + tool_name=tool_name, + ) + if step_context is not None + else None + ) + if accepted is None: # pragma: no cover - new contexts are mandatory here + raise ToolExecutionError( + "tool_context_corrupt", + "Accepted Tool Call is missing from Step Tool Context", + ) + inflight_cancel: CancelSignal | None = None + try: + validation_issues = validate_tool_arguments( + arguments, + accepted.entry.parameters_schema, + ) + except ToolValidationContractError as exc: + raise ToolExecutionError( + "tool_context_corrupt", + f"Accepted Tool schema is invalid: {exc}", + ) from exc + if validation_issues: + issue_summary = "; ".join( + issue.summary for issue in validation_issues + )[:2000] + messages.append( + _result_message( + run_id=run_id, + call_id=call_id, + tool_name=tool_name, + outcome=ToolExecutionOutcome( + status="failed", + result_summary=issue_summary, + result_ref=None, + error_code="tool_arguments_invalid", + model_action="repair_arguments", + side_effect_state="none", + safe_remediation=( + "Correct the listed argument paths and call " + "the same Tool again." + ), + ), + ) + ) + continue if tool_name == AT_TOOL_NAME: if not _is_group_agent_run(state): raise ToolExecutionError( @@ -1394,8 +1899,16 @@ async def execute_pending( messages=tuple(messages), waiting_request=approval_wait, pending_tool_calls=tool_calls[index:], + step_tool_context=step_context_update, + ) + policy = ( + ToolPolicy( + accepted.entry.effect, + accepted.entry.retry_policy, ) - policy = _policy(tool_name) + if accepted is not None + else _policy(tool_name) + ) lease_owner = _tool_execution_lease_owner( context.command_id, call_id, @@ -1408,6 +1921,8 @@ async def execute_pending( assistant_message_id=assistant_message_id, arguments=arguments, policy=policy, + provider_call_id=accepted.provider_call_id, + contract_version=accepted.entry.contract_version, lease_owner=lease_owner, reasoning_content=reasoning_content, assistant_content=assistant_content, @@ -1431,26 +1946,25 @@ async def execute_pending( outcome=reservation.reusable_result, ) ) - async with self._session_factory() as db: - async with db.begin(): - reused = reservation.reusable_result - await _insert_runtime_activity( - db, - tenant_id=tenant_id, - run_id=run_id, - key=f"activity:tool:{call_id}:{reused.status}", - summary=f"Runtime tool {tool_name} {reused.status}", - payload={ - "status": "done", - "activity_type": "tool_call", - "call_id": call_id, - "name": tool_name, - "args": dict(reservation.execution.sanitized_arguments or {}), - "result": reused.result_summary or "", - "execution_status": reused.status, - "error_code": reused.error_code, - }, - ) + async with self._session_factory() as db, db.begin(): + reused = reservation.reusable_result + await _insert_runtime_activity( + db, + tenant_id=tenant_id, + run_id=run_id, + key=f"activity:tool:{call_id}:{reused.status}", + summary=f"Runtime tool {tool_name} {reused.status}", + payload={ + "status": "done", + "activity_type": "tool_call", + "call_id": call_id, + "name": tool_name, + "args": dict(reservation.execution.sanitized_arguments or {}), + "result": reused.result_summary or "", + "execution_status": reused.status, + "error_code": reused.error_code, + }, + ) if tool_name == "send_message_to_agent" and self._a2a_service: waiting_request = a2a_waiting_request( source_run_id=run_id, @@ -1500,19 +2014,18 @@ async def execute_pending( continue if reconciliation.status == "unavailable": try: - async with self._session_factory() as db: - async with db.begin(): - execution = ( - await mark_expired_safe_read_result_unavailable( - db, - tenant_id=tenant_id, - execution_id=reservation.execution.id, - probe_error_code=( - reconciliation.error_code - or "tool_result_unavailable" - ), - ) + async with self._session_factory() as db, db.begin(): + execution = ( + await mark_expired_safe_read_result_unavailable( + db, + tenant_id=tenant_id, + execution_id=reservation.execution.id, + probe_error_code=( + reconciliation.error_code + or "tool_result_unavailable" + ), ) + ) except Exception as exc: raise ToolExecutionReconciliationPending( ( @@ -1866,22 +2379,18 @@ async def execute_pending( ) ) else: - agentbay_run_token = None - if tool_name.startswith("agentbay_"): - agentbay_run_token = agentbay_run_scope_id.set( - context.run_id - ) - try: - raw_result = await self._tool_executor( - tool_name, - arguments, - agent.id, - context.actor_user_id and uuid.UUID(context.actor_user_id) or agent.creator_id, - context.session_id or "", + raw_result, inflight_cancel = ( + await self._execute_application_with_controls( + state=state, + context=context, + tenant_id=tenant_id, + agent=agent, + accepted=accepted, + arguments=arguments, + reservation=reservation, + lease_owner=lease_owner, ) - finally: - if agentbay_run_token is not None: - agentbay_run_scope_id.reset(agentbay_run_token) + ) except GroupWorkspaceReconciliationPending: raise except Exception as exc: @@ -1954,6 +2463,20 @@ async def execute_pending( "Group workspace ledger settlement requires reconciliation" ) from exc raise + if inflight_cancel is not None: + messages.append( + _result_message( + run_id=run_id, + call_id=call_id, + tool_name=tool_name, + outcome=outcome, + ) + ) + return ToolStepResult( + messages=tuple(messages), + cancel_signal=inflight_cancel, + step_tool_context=step_context_update, + ) if outcome.status == "pending": return _async_pending_step_result( run_id=run_id, @@ -1997,6 +2520,7 @@ async def execute_pending( messages=tuple(messages), pending_group_at_changed=pending_group_at_changed, pending_group_at=pending_group_at, + step_tool_context=step_context_update, ) except ( GroupWorkspaceReconciliationPending, @@ -2017,4 +2541,9 @@ async def execute_pending( ) -__all__ = ["RuntimeToolStepService", "ToolPolicy"] +__all__ = [ + "LEGACY_TOOL_CONTEXT_DELETE_GATE", + "RuntimeToolStepService", + "ToolPolicy", + "legacy_tool_context_deletion_ready", +] diff --git a/backend/app/services/agent_runtime/tool_validation.py b/backend/app/services/agent_runtime/tool_validation.py new file mode 100644 index 000000000..07ffdfe3b --- /dev/null +++ b/backend/app/services/agent_runtime/tool_validation.py @@ -0,0 +1,222 @@ +"""Deterministic validation against the schema accepted by one Model Step.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass + +from app.services.agent_runtime.state import JsonObject + +MAX_VALIDATION_ISSUES = 20 +MAX_VALIDATION_PATH_LENGTH = 240 + + +class ToolValidationContractError(ValueError): + """The accepted Tool schema is malformed or unsupported.""" + + +@dataclass(frozen=True, slots=True) +class ToolValidationIssue: + """One bounded, value-free argument problem safe to show the model.""" + + code: str + path: str + summary: str + + +def _path(parent: str, child: str) -> str: + combined = f"{parent}.{child}" if parent != "$" else f"$.{child}" + return combined[:MAX_VALIDATION_PATH_LENGTH] + + +def _issue(code: str, path: str, summary: str) -> ToolValidationIssue: + return ToolValidationIssue(code=code, path=path, summary=summary[:300]) + + +def _matches_type(value: object, expected: str) -> bool: + if expected == "object": + return isinstance(value, Mapping) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "boolean": + return isinstance(value, bool) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and (not isinstance(value, float) or math.isfinite(value)) + ) + if expected == "null": + return value is None + raise ToolValidationContractError(f"unsupported schema type {expected!r}") + + +def _schema_object(value: object, *, field_name: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + raise ToolValidationContractError(f"{field_name} must be an object") + if any(not isinstance(key, str) for key in value): + raise ToolValidationContractError(f"{field_name} keys must be strings") + return value + + +def _validate( + value: object, + schema: Mapping[str, object], + *, + path: str, + issues: list[ToolValidationIssue], +) -> None: + if len(issues) >= MAX_VALIDATION_ISSUES: + return + raw_type = schema.get("type") + expected_types: tuple[str, ...] + if raw_type is None: + expected_types = () + elif isinstance(raw_type, str): + expected_types = (raw_type,) + elif isinstance(raw_type, list) and raw_type and all( + isinstance(item, str) for item in raw_type + ): + expected_types = tuple(raw_type) + else: + raise ToolValidationContractError("schema type must be text or an array of text") + if expected_types and not any(_matches_type(value, item) for item in expected_types): + issues.append( + _issue( + "type", + path, + f"{path} must have type {' or '.join(expected_types)}.", + ) + ) + return + + enum = schema.get("enum") + if enum is not None: + if not isinstance(enum, list) or not enum: + raise ToolValidationContractError("schema enum must be a non-empty array") + if value not in enum: + issues.append(_issue("enum", path, f"{path} must use one allowed value.")) + + if isinstance(value, Mapping): + raw_properties = schema.get("properties", {}) + properties = _schema_object(raw_properties, field_name="schema properties") + raw_required = schema.get("required", []) + if not isinstance(raw_required, list) or any( + not isinstance(item, str) for item in raw_required + ): + raise ToolValidationContractError("schema required must be an array of text") + for required_name in raw_required: + if required_name not in value: + missing_path = _path(path, required_name) + issues.append( + _issue( + "required", + missing_path, + f"{missing_path} is required.", + ) + ) + if len(issues) >= MAX_VALIDATION_ISSUES: + return + for property_name, property_schema in properties.items(): + if property_name not in value: + continue + child_schema = _schema_object( + property_schema, + field_name=f"schema property {property_name}", + ) + _validate( + value[property_name], + child_schema, + path=_path(path, property_name), + issues=issues, + ) + additional = schema.get("additionalProperties", True) + if not isinstance(additional, (bool, Mapping)): + raise ToolValidationContractError( + "schema additionalProperties must be a boolean or object" + ) + for property_name in value: + if property_name in properties: + continue + child_path = _path(path, str(property_name)) + if additional is False: + issues.append( + _issue( + "additional_property", + child_path, + f"{child_path} is not an accepted argument.", + ) + ) + elif isinstance(additional, Mapping): + _validate( + value[property_name], + _schema_object( + additional, + field_name="schema additionalProperties", + ), + path=child_path, + issues=issues, + ) + if len(issues) >= MAX_VALIDATION_ISSUES: + return + + if isinstance(value, list) and "items" in schema: + item_schema = _schema_object(schema["items"], field_name="schema items") + for index, item in enumerate(value): + _validate(item, item_schema, path=f"{path}[{index}]", issues=issues) + if len(issues) >= MAX_VALIDATION_ISSUES: + return + + alternatives = schema.get("anyOf") + if alternatives is not None: + if not isinstance(alternatives, list) or not alternatives: + raise ToolValidationContractError("schema anyOf must be a non-empty array") + matched = False + for alternative in alternatives: + candidate_issues: list[ToolValidationIssue] = [] + _validate( + value, + _schema_object(alternative, field_name="schema anyOf entry"), + path=path, + issues=candidate_issues, + ) + if not candidate_issues: + matched = True + break + if not matched: + issues.append( + _issue( + "any_of", + path, + f"{path} must satisfy one accepted argument shape.", + ) + ) + + +def validate_tool_arguments( + arguments: JsonObject, + parameters_schema: JsonObject, +) -> tuple[ToolValidationIssue, ...]: + """Return deterministic, bounded issues without echoing argument values.""" + if not isinstance(arguments, dict): + return (_issue("type", "$", "$ must have type object."),) + issues: list[ToolValidationIssue] = [] + _validate( + arguments, + _schema_object(parameters_schema, field_name="parameters schema"), + path="$", + issues=issues, + ) + return tuple(issues[:MAX_VALIDATION_ISSUES]) + + +__all__ = [ + "ToolValidationContractError", + "ToolValidationIssue", + "validate_tool_arguments", +] diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 0224cdf65..f26a2582b 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -14,7 +14,7 @@ import asyncio from collections.abc import Mapping from copy import deepcopy -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace import fnmatch import hashlib import json @@ -91,6 +91,13 @@ ToolExecutionOutcome, sanitize_tool_arguments, ) +from app.services.agent_runtime.tool_contracts import ( + resolve_tool_deadline_seconds, +) +from app.services.agent_runtime.tool_registry import ( + STATIC_REGISTERED_TOOL_NAMES, + resolve_registered_tool, +) _settings = get_settings() @@ -100,6 +107,8 @@ TEMP_WORKSPACE_DEFAULT_PATHS = ["workspace", "memory", "skills", "focus.md", "soul.md", "HEARTBEAT.md"] MAX_EXEC_STDOUT_CAPTURE_BYTES = 1_000_000 MAX_EXEC_STDERR_CAPTURE_BYTES = 500_000 +EMAIL_IMAP_DEADLINE_SECONDS = 30.0 +PUBLIC_DNS_DEADLINE_SECONDS = 10.0 _READ_FILE_BINARY_EXTENSIONS = frozenset( { ".7z", @@ -707,6 +716,92 @@ def _patch_computer_tool_descriptions(tools: list[dict], os_type: str) -> list[d return patched +def _project_active_tool_descriptions(tools: list[dict]) -> list[dict]: + """Remove instructions that point at tools absent from this exact Workset.""" + active_names = { + str(tool.get("function", {}).get("name") or "") for tool in tools + } + projected: list[dict] = [] + for original in tools: + tool = original + function = original.get("function", {}) + name = str(function.get("name") or "") + description = str(function.get("description") or "") + + replacements: list[tuple[str, str]] = [] + if name == "write_file" and "list_files" not in active_names: + replacements.append( + ( + "Before creating a new document under workspace/, first inspect " + "the relevant directories with list_files, prefer an existing " + "topical subfolder over the workspace root, and create a new " + "subfolder when the content belongs to a new category.", + "Before creating a new document under workspace/, use the current " + "context to prefer an existing topical subfolder over the workspace " + "root, and create a new subfolder when the content belongs to a new " + "category.", + ) + ) + if name == "read_file" and "read_document" not in active_names: + replacements.append( + ( + "use read_document for supported office documents.", + "the office-document extractor is unavailable in this Workset.", + ) + ) + if name == "update_objective": + if "get_my_okr" not in active_names: + replacements.append( + ( + "Regular agents can only update their own Objectives — call " + "get_my_okr first to get your objective_id.", + "Regular agents can only update their own Objectives; provide an " + "objective_id from the current context or user.", + ) + ) + if "create_objective" not in active_names: + replacements.append( + ( + "If the request is to revise an existing OKR's goal text rather " + "than create a new one, prefer this tool over create_objective.", + "Use this tool only to revise an existing Objective.", + ) + ) + + projected_description = description + for old, new in replacements: + projected_description = projected_description.replace(old, new) + + objective_id_description = None + if name == "update_objective" and not { + "get_my_okr", + "get_okr", + } <= active_names: + objective_id_description = ( + "UUID of the Objective to update. Provide an ID from the current " + "context or user." + ) + + if ( + projected_description != description + or objective_id_description is not None + ): + tool = deepcopy(original) + tool["function"]["description"] = projected_description + if objective_id_description is not None: + properties = ( + tool["function"] + .get("parameters", {}) + .get("properties", {}) + ) + if "objective_id" in properties: + properties["objective_id"][ + "description" + ] = objective_id_description + projected.append(tool) + return projected + + async def _agent_has_feishu(agent_id: uuid.UUID) -> bool: """Check deterministic local Feishu channel readiness.""" try: @@ -928,6 +1023,7 @@ async def get_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: ) # Inject OS-aware paths into computer-related tool descriptions result = _patch_computer_tool_descriptions(result, computer_os_type) + result = _project_active_tool_descriptions(result) # Final diagnostic: log the complete tool list and assignment stats final_names = sorted(t["function"]["name"] for t in result) logger.info( @@ -950,6 +1046,7 @@ async def get_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: # can leak disabled tools (for example search tools) into the LLM. Keep only # the minimal always-available core/channel tools. fallback = _patch_computer_tool_descriptions(_always_tools, computer_os_type) + fallback = _project_active_tool_descriptions(fallback) return fallback @@ -964,19 +1061,23 @@ def _runtime_typed_tools( dynamic MCP row may enter only through the separately resolved exact-name workset and may not replace Runtime control, Group, or builtin contracts. """ - return [ - tool - for tool in tools - if ( - (name := str(tool.get("function", {}).get("name") or "")) - in RUNTIME_TYPED_APPLICATION_TOOL_NAMES - or ( - name in dynamic_mcp_names - and name not in BUILTIN_TOOL_NAMES - and not is_reserved_custom_tool_name(name) - ) + resolved: list[dict] = [] + for tool in tools: + name = str(tool.get("function", {}).get("name") or "") + registered = resolve_registered_tool( + tool, + dynamic_mcp_names=dynamic_mcp_names, ) - ] + if name in STATIC_REGISTERED_TOOL_NAMES: + if registered is not None: + resolved.append(tool) + continue + if ( + registered is not None + or name in RUNTIME_TYPED_APPLICATION_TOOL_NAMES + ): + resolved.append(tool) + return resolved async def _agent_is_designated_okr_agent(agent_id: uuid.UUID) -> bool: @@ -1046,7 +1147,7 @@ async def _get_runtime_dynamic_mcp_tool_names( ) continue ready.add(name) - return ready + return _project_active_tool_descriptions(ready) async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: @@ -1284,7 +1385,7 @@ async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: for tool in tools } - RUNTIME_TYPED_APPLICATION_TOOL_NAMES - - dynamic_mcp_names + - set(dynamic_mcp_names) - {""} ) if hidden: @@ -3879,9 +3980,16 @@ async def _validate_public_http_url(url: str) -> tuple[str | None, str | None]: addresses = [hostname] else: loop = asyncio.get_running_loop() - infos = await loop.run_in_executor( - None, - lambda: socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM), + infos = await asyncio.wait_for( + loop.run_in_executor( + None, + lambda: socket.getaddrinfo( + hostname, + parsed.port or (443 if parsed.scheme == "https" else 80), + type=socket.SOCK_STREAM, + ), + ), + timeout=PUBLIC_DNS_DEADLINE_SECONDS, ) addresses = [info[4][0] for info in infos] except Exception as exc: @@ -6678,6 +6786,42 @@ class DocumentReadResult: content: str error_code: str | None = None retryable: bool = False + truncated: bool = False + processed_scope: dict[str, Any] = field(default_factory=dict) + truncation_reasons: tuple[str, ...] = () + + +def _complete_document_read( + content: str, + *, + max_chars: int, + processed_scope: dict[str, Any] | None = None, + truncation_reasons: list[str] | None = None, +) -> DocumentReadResult: + """Return content with an explicit, machine-readable incompleteness fact.""" + scope = dict(processed_scope or {}) + reasons = list(dict.fromkeys(truncation_reasons or [])) + if len(content) > max_chars: + scope["characters_total"] = len(content) + scope["characters_returned"] = max_chars + reasons.append( + f"returned the first {max_chars} of {len(content)} extracted characters" + ) + content = content[:max_chars] + reasons = list(dict.fromkeys(reasons)) + if reasons: + content += ( + "\n\n[Document output incomplete: " + + "; ".join(reasons) + + ". No continuation parameter is available.]" + ) + return DocumentReadResult( + True, + content, + truncated=bool(reasons), + processed_scope=scope, + truncation_reasons=tuple(reasons), + ) def _safe_document_cell_text(value: Any) -> str: @@ -6732,17 +6876,30 @@ def _read_document_sync( ) ext = file_path.suffix.lower() + processed_scope: dict[str, Any] = {} + truncation_reasons: list[str] = [] try: if ext == ".pdf": import pdfplumber text_parts = [] with pdfplumber.open(str(file_path)) as pdf: + total_pages = len(pdf.pages) + processed_pages = 0 for i, page in enumerate(pdf.pages[:50]): # Limit to 50 pages + processed_pages = i + 1 page_text = page.extract_text() or "" if page_text: text_parts.append(f"--- Page {i+1} ---\n{page_text}") if sum(len(part) for part in text_parts) >= max_chars: break + processed_scope.update( + pages_processed=processed_pages, + pages_total=total_pages, + ) + if processed_pages < total_pages: + truncation_reasons.append( + f"processed the first {processed_pages} of {total_pages} pages" + ) content = "\n\n".join(text_parts) if text_parts else "(PDF is empty or text extraction failed)" elif ext == ".docx": @@ -6802,14 +6959,29 @@ def _extract_table(table) -> str: wb = load_workbook(str(file_path), read_only=True, data_only=True) sheets = [] cell_count = 0 + processed_sheets = 0 + total_sheets = len(wb.sheetnames) for ws_name in wb.sheetnames[:10]: # Limit to 10 sheets + processed_sheets += 1 sheet = wb[ws_name] rows = [] + if sheet.max_row > 200: + truncation_reasons.append( + f"sheet {ws_name} processed the first 200 of {sheet.max_row} rows" + ) + if sheet.max_column > _READ_DOCUMENT_MAX_COLUMNS: + truncation_reasons.append( + f"sheet {ws_name} processed the first {_READ_DOCUMENT_MAX_COLUMNS} " + f"of {sheet.max_column} columns" + ) for row in sheet.iter_rows(max_row=200, max_col=_READ_DOCUMENT_MAX_COLUMNS, values_only=True): visible = row cell_count += len(visible) if cell_count > _READ_DOCUMENT_MAX_XLSX_CELLS: rows.append("[cell limit reached; remaining cells omitted]") + truncation_reasons.append( + f"stopped after the {_READ_DOCUMENT_MAX_XLSX_CELLS}-cell safety limit" + ) break row_str = "\t".join(_safe_document_cell_text(c) for c in visible) if row_str.strip(): @@ -6819,21 +6991,41 @@ def _extract_table(table) -> str: if cell_count > _READ_DOCUMENT_MAX_XLSX_CELLS or sum(len(part) for part in sheets) >= max_chars: break wb.close() + processed_scope.update( + sheets_processed=processed_sheets, + sheets_total=total_sheets, + cells_processed=min(cell_count, _READ_DOCUMENT_MAX_XLSX_CELLS), + ) + if processed_sheets < total_sheets: + truncation_reasons.append( + f"processed the first {processed_sheets} of {total_sheets} sheets" + ) content = "\n\n".join(sheets) if sheets else "(Excel is empty)" elif ext == ".pptx": from pptx import Presentation prs = Presentation(str(file_path)) slides = [] + total_slides = len(prs.slides) + processed_slides = 0 for i, slide in enumerate(prs.slides): if i >= 50: break + processed_slides = i + 1 texts = [] for shape in slide.shapes: if hasattr(shape, "text") and shape.text.strip(): texts.append(shape.text) if texts: slides.append(f"--- Slide {i+1} ---\n" + "\n".join(texts)) + processed_scope.update( + slides_processed=processed_slides, + slides_total=total_slides, + ) + if processed_slides < total_slides: + truncation_reasons.append( + f"processed the first {processed_slides} of {total_slides} slides" + ) content = "\n\n".join(slides) if slides else "(PPT is empty)" elif ext in (".txt", ".md", ".json", ".csv", ".log"): @@ -6846,9 +7038,12 @@ def _extract_table(table) -> str: "document_format_unsupported", ) - if len(content) > max_chars: - content = content[:max_chars] + f"\n\n...[truncated, {len(content)} chars total]" - return DocumentReadResult(True, content) + return _complete_document_read( + content, + max_chars=max_chars, + processed_scope=processed_scope, + truncation_reasons=truncation_reasons, + ) except ImportError as e: return DocumentReadResult( @@ -6919,16 +7114,30 @@ def _read_pdf_fast_sync( text_parts = [] with fitz.open(str(file_path)) as doc: + total_pages = len(doc) + processed_pages = 0 for i, page in enumerate(doc[:50]): + processed_pages = i + 1 page_text = page.get_text("text") or "" if page_text: text_parts.append(f"--- Page {i+1} ---\n{page_text}") if sum(len(part) for part in text_parts) >= max_chars: break content = "\n\n".join(text_parts) if text_parts else "(PDF is empty or text extraction failed)" - if len(content) > max_chars: - content = content[:max_chars] + f"\n\n...[truncated, {len(content)} chars total]" - return DocumentReadResult(True, content) + reasons = [] + if processed_pages < total_pages: + reasons.append( + f"processed the first {processed_pages} of {total_pages} pages" + ) + return _complete_document_read( + content, + max_chars=max_chars, + processed_scope={ + "pages_processed": processed_pages, + "pages_total": total_pages, + }, + truncation_reasons=reasons, + ) except ImportError as exc: return DocumentReadResult( False, @@ -7199,9 +7408,19 @@ async def _read_document_outcome( finally: temp_workspace.cleanup() if result.ok: + metadata: dict[str, Any] = {} + if result.truncated: + metadata = { + "content_truncated": True, + "document_processed_scope": result.processed_scope, + "document_truncation_reasons": list( + result.truncation_reasons + ), + } return _typed_success( result.content, evidence_refs=(_workspace_artifact_ref(agent_id, path),), + metadata=metadata, ) return _typed_failure( result.content, @@ -10274,6 +10493,11 @@ async def read_stream(stream, out, label="stdout"): return _typed_success("\n\n".join(result_parts)) + except asyncio.CancelledError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.wait() + raise except Exception as e: if proc is not None: try: @@ -10496,45 +10720,167 @@ async def _handle_set_trigger_outcome( ) # Validate type-specific config + allowed_config_keys = { + "cron": {"expr", "timezone"}, + "once": {"at"}, + "interval": {"minutes"}, + "poll": { + "url", + "interval_min", + "method", + "headers", + "json_path", + "fire_on", + "match_value", + }, + "on_message": {"from_agent_name", "from_user_name"}, + "webhook": set(), + }[ttype] + unexpected_config_keys = sorted(set(config) - allowed_config_keys) + if unexpected_config_keys: + return _typed_failure( + f"{ttype} trigger config contains unsupported fields: " + + ", ".join(unexpected_config_keys), + "invalid_tool_arguments", + ) if ttype == "cron": expr = config.get("expr", "") - if not expr: + timezone_name = config.get("timezone") + if not isinstance(expr, str) or not expr.strip(): + return _typed_failure( + "cron trigger requires string config.expr.", + "invalid_tool_arguments", + ) + if timezone_name is not None and ( + not isinstance(timezone_name, str) or not timezone_name.strip() + ): return _typed_failure( - "cron trigger requires config.expr.", + "cron trigger config.timezone must be a non-empty string.", "invalid_tool_arguments", ) try: from croniter import croniter - croniter(expr) + croniter(expr.strip()) + if timezone_name: + from zoneinfo import ZoneInfo + + ZoneInfo(timezone_name.strip()) except Exception: + return _typed_failure("Invalid cron config.", "invalid_tool_arguments") + config["expr"] = expr.strip() + if timezone_name: + config["timezone"] = timezone_name.strip() + elif ttype == "once": + at_value = config.get("at") + if not isinstance(at_value, str) or not at_value.strip(): return _typed_failure( - f"Invalid cron expression: '{expr}'.", + "once trigger requires ISO-8601 string config.at.", "invalid_tool_arguments", ) - elif ttype == "once": - if not config.get("at"): + try: + parsed_at = datetime.fromisoformat(at_value.strip()) + except ValueError: return _typed_failure( - "once trigger requires config.at.", + "once trigger config.at must be a valid ISO-8601 date-time.", "invalid_tool_arguments", ) + config["at"] = parsed_at.isoformat() elif ttype == "interval": - if not config.get("minutes"): + minutes = config.get("minutes") + if ( + not isinstance(minutes, int) + or isinstance(minutes, bool) + or not 1 <= minutes <= 525_600 + ): return _typed_failure( - "interval trigger requires config.minutes.", + "interval trigger config.minutes must be an integer from 1 through 525600.", "invalid_tool_arguments", ) elif ttype == "poll": - if not config.get("url"): + from urllib.parse import urlparse + + url = config.get("url") + if ( + not isinstance(url, str) + or urlparse(url.strip()).scheme not in {"http", "https"} + or not urlparse(url.strip()).netloc + ): return _typed_failure( - "poll trigger requires config.url.", + "poll trigger requires an absolute HTTP(S) config.url.", "invalid_tool_arguments", ) + interval_min = config.get("interval_min", 5) + if ( + not isinstance(interval_min, int) + or isinstance(interval_min, bool) + or interval_min <= 0 + ): + return _typed_failure( + "poll trigger config.interval_min must be a positive integer.", + "invalid_tool_arguments", + ) + method = config.get("method", "GET") + if method not in {"GET", "HEAD"}: + return _typed_failure( + "poll trigger config.method must be GET or HEAD.", + "invalid_tool_arguments", + ) + headers = config.get("headers", {}) + if not isinstance(headers, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in headers.items() + ): + return _typed_failure( + "poll trigger config.headers must contain only string values.", + "invalid_tool_arguments", + ) + fire_on = config.get("fire_on", "change") + if fire_on not in {"change", "match"}: + return _typed_failure( + "poll trigger config.fire_on must be change or match.", + "invalid_tool_arguments", + ) + if fire_on == "match" and "match_value" not in config: + return _typed_failure( + "poll trigger config.match_value is required when fire_on is match.", + "invalid_tool_arguments", + ) + json_path = config.get("json_path") + if json_path is not None and not isinstance(json_path, str): + return _typed_failure( + "poll trigger config.json_path must be a string.", + "invalid_tool_arguments", + ) + config["url"] = url.strip() + config["method"] = method + config["interval_min"] = interval_min + config["fire_on"] = fire_on elif ttype == "on_message": - if not config.get("from_agent_name") and not config.get("from_user_name"): + agent_name = config.get("from_agent_name") + user_name = config.get("from_user_name") + if agent_name is not None and ( + not isinstance(agent_name, str) or not agent_name.strip() + ): + return _typed_failure( + "on_message config.from_agent_name must be a non-empty string.", + "invalid_tool_arguments", + ) + if user_name is not None and ( + not isinstance(user_name, str) or not user_name.strip() + ): + return _typed_failure( + "on_message config.from_user_name must be a non-empty string.", + "invalid_tool_arguments", + ) + if not agent_name and not user_name: return _typed_failure( "on_message trigger requires from_agent_name or from_user_name.", "invalid_tool_arguments", ) + if agent_name: + config["from_agent_name"] = agent_name.strip() + if user_name: + config["from_user_name"] = user_name.strip() # Snapshot the latest message timestamp so we only detect NEW messages after this point # This prevents false positives from already-processed messages try: @@ -16955,7 +17301,16 @@ def read_mailbox() -> list[dict[str, str]]: return messages try: - messages = await asyncio.to_thread(read_mailbox) + messages = await asyncio.wait_for( + asyncio.to_thread(read_mailbox), + timeout=EMAIL_IMAP_DEADLINE_SECONDS, + ) + except TimeoutError: + return _typed_failure( + "IMAP read exceeded its operation deadline.", + "email_imap_deadline_exceeded", + retryable=True, + ) except _EmailIMAPRejected as exc: return _typed_failure( f"IMAP rejected the {exc.stage} operation.", @@ -18155,14 +18510,37 @@ async def _agentbay_read_outcome( elif tool_name == "agentbay_browser_extract": instruction = cast(str, arguments.get("instruction")) selector = cast(str, arguments.get("selector", "")) - result = await client.browser_extract(instruction, selector) + result = await client.browser_extract( + instruction, + selector, + timeout=int( + resolve_tool_deadline_seconds( + "agentbay_read", arguments.get("timeout") + ) + ), + ) elif tool_name == "agentbay_browser_observe": instruction = cast(str, arguments.get("instruction")) selector = cast(str, arguments.get("selector", "")) - result = await client.browser_observe(instruction, selector) + result = await client.browser_observe( + instruction, + selector, + timeout=int( + resolve_tool_deadline_seconds( + "agentbay_read", arguments.get("timeout") + ) + ), + ) elif tool_name == "agentbay_code_read_file": remote_path = cast(str, arguments.get("remote_path")) - result = await client.code_read_file(remote_path) + result = await client.code_read_file( + remote_path, + timeout=int( + resolve_tool_deadline_seconds( + "agentbay_read", arguments.get("timeout") + ) + ), + ) elif tool_name in { "agentbay_computer_screenshot", "agentbay_computer_precision_screenshot", @@ -18391,7 +18769,15 @@ async def _agentbay_code_execute(agent_id: Optional[uuid.UUID], ws: Path, argume language = arguments.get("language", "python") code = arguments.get("code", "") - timeout = arguments.get("timeout", 30) + try: + timeout = int( + resolve_tool_deadline_seconds( + "agentbay_code", + arguments.get("timeout"), + ) + ) + except ValueError: + return "❌ timeout 必须是正数" if not code.strip(): return "❌ 请提供要执行的代码" @@ -18471,9 +18857,14 @@ async def _agentbay_code_read_file(agent_id: Optional[uuid.UUID], ws: Path, argu try: _session_id, _run_id = _agentbay_scope_ids(arguments) client = await get_agentbay_client_for_agent(agent_id, "code", session_id=_session_id, run_id=_run_id) - result = await asyncio.to_thread( - client._session.file_system.read_file, + result = await client.code_read_file( remote_path, + timeout=int( + resolve_tool_deadline_seconds( + "agentbay_read", + arguments.get("timeout"), + ) + ), ) if result.success: content = getattr(result, "content", "") or "" diff --git a/backend/app/services/agentbay_client.py b/backend/app/services/agentbay_client.py index 9ae86de9a..79ecdf40f 100644 --- a/backend/app/services/agentbay_client.py +++ b/backend/app/services/agentbay_client.py @@ -286,7 +286,10 @@ async def code_execute(self, language: str, code: str, timeout: int = 30) -> dic if not self._session or self._image_type not in ("code", "code_latest"): await self.create_session("code_latest") - result = await asyncio.to_thread(self._session.code.run_code, code, sdk_lang) + result = await asyncio.wait_for( + asyncio.to_thread(self._session.code.run_code, code, sdk_lang), + timeout=timeout, + ) return { "stdout": result.result if result.success else "", @@ -295,18 +298,26 @@ async def code_execute(self, language: str, code: str, timeout: int = 30) -> dic "success": result.success, } - async def code_read_file(self, remote_path: str): + async def code_read_file(self, remote_path: str, timeout: int = 30): """Read a code-sandbox file while preserving the SDK result facts.""" if not self._session or self._image_type not in ("code", "code_latest"): await self.create_session("code_latest") - return await asyncio.to_thread( - self._session.file_system.read_file, - remote_path, + return await asyncio.wait_for( + asyncio.to_thread( + self._session.file_system.read_file, + remote_path, + ), + timeout=timeout, ) # ─── Browser: Extract & Observe ─────────────────── - async def browser_extract(self, instruction: str, selector: str = "") -> dict: + async def browser_extract( + self, + instruction: str, + selector: str = "", + timeout: int = 30, + ) -> dict: """Extract structured data from current page using natural language instruction.""" await self._ensure_browser_initialized() @@ -320,15 +331,21 @@ async def browser_extract(self, instruction: str, selector: str = "") -> dict: schema=GenericExtractSchema, selector=selector or None, ) - success, data = await asyncio.to_thread( - self._session.browser.operator.extract, options + success, data = await asyncio.wait_for( + asyncio.to_thread(self._session.browser.operator.extract, options), + timeout=timeout, ) if success and data: if hasattr(data, "model_dump"): data = data.model_dump() return {"success": success, "data": data} - async def browser_observe(self, instruction: str, selector: str = "") -> dict: + async def browser_observe( + self, + instruction: str, + selector: str = "", + timeout: int = 30, + ) -> dict: """Observe the current page state and return interactive elements.""" await self._ensure_browser_initialized() @@ -340,8 +357,9 @@ async def browser_observe(self, instruction: str, selector: str = "") -> dict: instruction=instruction, selector=selector or None, ) - success, results = await asyncio.to_thread( - self._session.browser.operator.observe, options + success, results = await asyncio.wait_for( + asyncio.to_thread(self._session.browser.operator.observe, options), + timeout=timeout, ) # Convert ObserveResult objects to dicts for serialization result_dicts = [] diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 83f6b0054..0fce33a70 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -241,7 +241,7 @@ { "name": "read_document", "display_name": "Read Document", - "description": "Read office document contents (PDF, Word, Excel, PPT) and extract text.", + "description": "Extract embedded text from PDF, Word, Excel, or PowerPoint files. This tool does not perform OCR. Output is bounded and a truncated result explicitly reports the processed scope; there is currently no page, sheet, or cursor continuation parameter.", "category": "file", "icon": "📑", "is_default": True, @@ -368,7 +368,26 @@ "properties": { "name": {"type": "string", "description": "Unique name for this trigger"}, "type": {"type": "string", "enum": ["cron", "once", "interval", "poll", "on_message", "webhook"], "description": "Trigger type"}, - "config": {"type": "object", "description": "Type-specific config. cron: {\"expr\": \"0 9 * * *\"}. once: {\"at\": \"2026-03-10T09:00:00+08:00\"}. interval: {\"minutes\": 30}. poll: {\"url\": \"...\", \"json_path\": \"$.status\"}. on_message: {\"from_agent_name\": \"Morty\"} or {\"from_user_name\": \"张三\"}"}, + "config": { + "type": "object", + "description": "Type-specific config. Supply only fields used by the selected trigger type.", + "properties": { + "expr": {"type": "string", "description": "cron: a valid cron expression."}, + "timezone": {"type": "string", "description": "cron: optional IANA timezone."}, + "at": {"type": "string", "description": "once: ISO-8601 date-time."}, + "minutes": {"type": "integer", "description": "interval: positive interval in minutes."}, + "url": {"type": "string", "description": "poll: public HTTP(S) URL."}, + "interval_min": {"type": "integer", "description": "poll: positive polling interval in minutes."}, + "method": {"type": "string", "enum": ["GET", "HEAD"], "description": "poll: HTTP method."}, + "headers": {"type": "object", "additionalProperties": {"type": "string"}, "description": "poll: optional string headers."}, + "json_path": {"type": "string", "description": "poll: response JSON path."}, + "fire_on": {"type": "string", "enum": ["change", "match"], "description": "poll: fire on value change or exact match."}, + "match_value": {"type": ["string", "number", "boolean", "null"], "description": "poll: value used when fire_on=match."}, + "from_agent_name": {"type": "string", "description": "on_message: exact Agent name."}, + "from_user_name": {"type": "string", "description": "on_message: exact user name."}, + }, + "additionalProperties": False, + }, "reason": {"type": "string", "minLength": 1, "description": "Self-contained instruction describing exactly what to do when this trigger fires."}, "focus_ref": {"type": "string", "description": "Optional: which focus item this relates to. If omitted, one is created automatically."}, }, @@ -2838,6 +2857,12 @@ "type": "string", "description": "Absolute path inside the code sandbox, e.g. /home/wuying/main.py", }, + "timeout": { + "type": "integer", + "minimum": 1, + "description": "Operation deadline in seconds (maximum 60).", + "default": 30, + }, }, "required": ["remote_path"], }, @@ -2894,6 +2919,7 @@ "properties": { "instruction": {"type": "string", "description": "Natural language description of what data to extract, e.g. 'extract all product names and prices'"}, "selector": {"type": "string", "description": "Optional CSS selector to scope the extraction to a specific element"}, + "timeout": {"type": "integer", "minimum": 1, "description": "Operation deadline in seconds (maximum 60).", "default": 30}, }, "required": ["instruction"], }, @@ -2912,6 +2938,7 @@ "properties": { "instruction": {"type": "string", "description": "Natural language description of what to observe, e.g. 'find the login button' or 'list all navigation links'"}, "selector": {"type": "string", "description": "Optional CSS selector to scope observation"}, + "timeout": {"type": "integer", "minimum": 1, "description": "Operation deadline in seconds (maximum 60).", "default": 30}, }, "required": ["instruction"], }, diff --git a/backend/app/services/llm/caller.py b/backend/app/services/llm/caller.py index f854bc06a..fcc9809e9 100644 --- a/backend/app/services/llm/caller.py +++ b/backend/app/services/llm/caller.py @@ -66,7 +66,7 @@ async def execute_tool(*args, **kwargs): "send_message_to_agent", "send_feishu_message", "send_email" }) -WRITE_FILE_PROTOCOL_REPAIR_LIMIT = 3 +WRITE_FILE_PROTOCOL_REPAIR_LIMIT = 10 WRITE_FILE_PROTOCOL_REPAIR_COUNTER_KEY = "invalid_tool_call:write_file" WRITE_FILE_PROTOCOL_REPAIR_INSTRUCTION = ( "Your previous `write_file` call was not executed because `function.arguments` " @@ -788,7 +788,7 @@ async def _buffer_chunk(_text: str) -> None: repair_limit = ( WRITE_FILE_PROTOCOL_REPAIR_LIMIT if retry_tool_name == "write_file" - else 1 + else 10 ) repair_counter_key = ( WRITE_FILE_PROTOCOL_REPAIR_COUNTER_KEY diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index d30105509..f866e5780 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -573,10 +573,12 @@ def __init__( model: str | None = None, timeout: float = 120.0, supports_tool_choice: bool = True, + supports_parallel_tool_calls: bool = False, supports_cache_control: bool = False, ): super().__init__(api_key, base_url or self.DEFAULT_BASE_URL, model, timeout) self.supports_tool_choice = supports_tool_choice + self.supports_parallel_tool_calls = supports_parallel_tool_calls self.supports_cache_control = supports_cache_control self._client: httpx.AsyncClient | None = None @@ -630,6 +632,7 @@ def _build_payload( payload["tools"] = tools if self.supports_tool_choice: payload["tool_choice"] = "auto" + if self.supports_parallel_tool_calls: payload["parallel_tool_calls"] = True # Add any additional kwargs @@ -1041,9 +1044,11 @@ def __init__( model: str | None = None, timeout: float = 120.0, supports_tool_choice: bool = True, + supports_parallel_tool_calls: bool = False, ): super().__init__(api_key, base_url or self.DEFAULT_BASE_URL, model, timeout) self.supports_tool_choice = supports_tool_choice + self.supports_parallel_tool_calls = supports_parallel_tool_calls self._client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: @@ -1231,6 +1236,8 @@ def _build_payload( payload["tools"] = converted_tools if self.supports_tool_choice: payload["tool_choice"] = "auto" + if self.supports_parallel_tool_calls: + payload["parallel_tool_calls"] = True payload.update(kwargs) final_input = payload.get("input") @@ -1482,6 +1489,7 @@ async def _get_openai_fallback_client(self) -> OpenAICompatibleClient: model=self.model, timeout=self.timeout, supports_tool_choice=self.supports_tool_choice, + supports_parallel_tool_calls=False, supports_cache_control=False, ) return self._openai_fallback_client @@ -2300,6 +2308,7 @@ class ProviderSpec: protocol: Literal["openai_compatible", "anthropic", "openai_responses", "gemini"] default_base_url: str | None supports_tool_choice: bool = True + supports_parallel_tool_calls: bool = False default_max_tokens: int = 4096 model_max_tokens: dict[str, int] = field(default_factory=dict) @@ -2326,6 +2335,7 @@ class ProviderSpec: display_name="OpenAI", protocol="openai_compatible", default_base_url="https://api.openai.com/v1", + supports_parallel_tool_calls=True, default_max_tokens=16384, ), "openai-response": ProviderSpec( @@ -2333,6 +2343,7 @@ class ProviderSpec: display_name="OpenAI Responses", protocol="openai_responses", default_base_url="https://api.openai.com/v1", + supports_parallel_tool_calls=True, default_max_tokens=16384, ), "azure": ProviderSpec( @@ -2340,6 +2351,7 @@ class ProviderSpec: display_name="Azure OpenAI", protocol="openai_compatible", default_base_url=None, + supports_parallel_tool_calls=True, default_max_tokens=16384, ), "deepseek": ProviderSpec( @@ -2354,6 +2366,7 @@ class ProviderSpec: display_name="Qwen (DashScope)", protocol="openai_compatible", default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + supports_parallel_tool_calls=True, default_max_tokens=8192, model_max_tokens={ "qwen-plus": 16384, @@ -2457,6 +2470,7 @@ def get_provider_manifest() -> list[dict[str, Any]]: "protocol": spec.protocol, "default_base_url": spec.default_base_url, "supports_tool_choice": spec.supports_tool_choice, + "supports_parallel_tool_calls": spec.supports_parallel_tool_calls, "default_max_tokens": spec.default_max_tokens, "model_max_tokens": spec.model_max_tokens, "aliases": [k for k, v in PROVIDER_ALIASES.items() if v == spec.provider], @@ -2579,6 +2593,7 @@ def create_llm_client( model=model, timeout=timeout, supports_tool_choice=spec.supports_tool_choice, + supports_parallel_tool_calls=spec.supports_parallel_tool_calls, ) elif spec and spec.protocol == "gemini": return GeminiClient( @@ -2596,6 +2611,9 @@ def create_llm_client( model=model, timeout=timeout, supports_tool_choice=supports_tool_choice, + supports_parallel_tool_calls=( + spec.supports_parallel_tool_calls if spec else False + ), supports_cache_control=normalized_provider == "qwen", ) else: @@ -2606,6 +2624,7 @@ def create_llm_client( model=model, timeout=timeout, supports_tool_choice=True, + supports_parallel_tool_calls=False, supports_cache_control=False, ) diff --git a/backend/app/services/llm/utils.py b/backend/app/services/llm/utils.py index 27f2b5b47..046318409 100644 --- a/backend/app/services/llm/utils.py +++ b/backend/app/services/llm/utils.py @@ -59,18 +59,20 @@ def get_model_api_key(model: LLMModel) -> str: def get_tool_params(provider: str) -> dict: """Return provider-specific tool calling parameters. - Qwen and OpenAI support `tool_choice` and `parallel_tool_calls`. - Anthropic uses a different tool calling format, so we skip these params. + Provider support for choosing a Tool and emitting multiple Tool Calls are + separate wire capabilities. Neither flag implies concurrent business + execution; Durable Runtime still applies accepted calls sequentially. Note: This function is kept for backward compatibility. The new client classes handle this internally. """ - if provider in TOOL_CHOICE_PROVIDERS: - return { - "tool_choice": "auto", - "parallel_tool_calls": True, - } - return {} + spec = get_provider_spec(provider) + if spec is None or not spec.supports_tool_choice: + return {} + params = {"tool_choice": "auto"} + if spec.supports_parallel_tool_calls: + params["parallel_tool_calls"] = True + return params def convert_chat_messages_to_llm_format(messages) -> list[dict]: diff --git a/backend/tests/test_agent_runtime_a2a.py b/backend/tests/test_agent_runtime_a2a.py index b77848784..728e1ef59 100644 --- a/backend/tests/test_agent_runtime_a2a.py +++ b/backend/tests/test_agent_runtime_a2a.py @@ -2,10 +2,10 @@ from __future__ import annotations +import uuid from collections import deque from types import SimpleNamespace from unittest.mock import AsyncMock, patch -import uuid import pytest @@ -160,6 +160,8 @@ def _records() -> tuple[uuid.UUID, Agent, Agent, AgentRun, ToolExecutionReservat tenant_id=tenant_id, run_id=source_run.id, tool_call_id="delegate-call", + provider_call_id="provider-delegate-call", + contract_version="runtime:send_message_to_agent:v1", tool_name="send_message_to_agent", assistant_message_id="assistant-message", arguments_hash="hash", @@ -414,6 +416,13 @@ async def mark_succeeded(mark_db, **kwargs): assert result.target_run_id == target_run_id assert result.outcome.status == "succeeded" assert result.outcome.result_ref == f"agent-run:{target_run_id}" + assert result.outcome.metadata["call_instance_id"] == "delegate-call" + assert result.outcome.metadata["provider_call_id"] == ( + "provider-delegate-call" + ) + assert result.outcome.metadata["execution_id"] == str( + reservation.execution.id + ) assert result.waiting_request == { "waiting_type": "agent", "correlation_id": ( @@ -440,6 +449,14 @@ async def mark_succeeded(mark_db, **kwargs): uuid.uuid5(source_run.id, "a2a-input:delegate-call") ) assert command.payload["input_content"] == "Research the latest facts" + assert command.payload["source_call_instance_id"] == "delegate-call" + assert command.payload["source_provider_call_id"] == "provider-delegate-call" + assert command.payload["source_tool_execution_id"] == str( + reservation.execution.id + ) + assert command.payload["source_tool_contract_version"] == ( + "runtime:send_message_to_agent:v1" + ) assert "a2a_message" not in command.payload assert cycle_guard.calls[0]["source_run_id"] == source_run.id messages = [value for value in db.added if isinstance(value, ChatMessage)] diff --git a/backend/tests/test_agent_runtime_async_tool_poll.py b/backend/tests/test_agent_runtime_async_tool_poll.py index 293b67723..ee6c34564 100644 --- a/backend/tests/test_agent_runtime_async_tool_poll.py +++ b/backend/tests/test_agent_runtime_async_tool_poll.py @@ -2,9 +2,9 @@ from __future__ import annotations +import uuid from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta -import uuid import pytest from sqlalchemy.dialects import postgresql @@ -12,7 +12,6 @@ from app.models.agent_tool_execution import AgentToolExecution from app.services.agent_runtime import async_tool_poll - _NOW = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) @@ -66,6 +65,8 @@ def _pending_execution(*, due_at: datetime, scheduled: bool = False): tenant_id=tenant_id, run_id=run_id, tool_call_id="launch-call", + provider_call_id="provider-launch-call", + contract_version="runtime:arxiv-download:v1", tool_name="arxiv_local-download_paper", assistant_message_id="assistant-1", arguments_hash="hash", @@ -126,9 +127,13 @@ async def enqueue(db, **kwargs): "async_poll_correlation_id" ], "payload": { - "operation_key": "operation-key", - "tool_call_id": "launch-call", - "poll_call_id": "poll-call", + "operation_key": "operation-key", + "tool_call_id": "launch-call", + "call_instance_id": "launch-call", + "tool_execution_id": str(execution.id), + "provider_call_id": "provider-launch-call", + "tool_contract_version": "runtime:arxiv-download:v1", + "poll_call_id": "poll-call", "poll": { "tool": "arxiv_local-download_paper", "arguments": { diff --git a/backend/tests/test_agent_runtime_cancel_source.py b/backend/tests/test_agent_runtime_cancel_source.py index 13a2c34e9..db9ecdc5e 100644 --- a/backend/tests/test_agent_runtime_cancel_source.py +++ b/backend/tests/test_agent_runtime_cancel_source.py @@ -2,8 +2,8 @@ from __future__ import annotations -from datetime import UTC, datetime import uuid +from datetime import UTC, datetime import pytest @@ -11,7 +11,9 @@ from app.services.agent_runtime.cancel_source import ( DatabaseRuntimeCancelSource, RuntimeCancelSourceError, + RuntimeToolCancelToken, ) +from app.services.agent_runtime.node_executor import CancelSignal from app.services.agent_runtime.state import ( RunInputSnapshots, RunRegistrySnapshot, @@ -192,3 +194,33 @@ async def test_rejects_malformed_persisted_cancel_reason() -> None: ) assert raised.value.code == "invalid_cancel_payload" + + +@pytest.mark.asyncio +async def test_tool_cancel_token_propagates_signal_and_capability_telemetry() -> None: + tenant_id = uuid.uuid4() + run_id = uuid.uuid4() + + class Source: + async def get_cancel(self, state, context): + assert state["lifecycle"]["status"] == "running" + assert context.run_id == str(run_id) + return CancelSignal(command_id="cancel-1", reason="user_abort") + + token = RuntimeToolCancelToken( + source=Source(), + state=_state(tenant_id, run_id), + context=_context(tenant_id, run_id), + capability="stop_waiting_only", + ) + + signal = await token.poll() + + assert signal is not None + assert token.telemetry(signal) == { + "cancel_requested": True, + "cancel_command_id": "cancel-1", + "cancel_reason": "user_abort", + "cancel_capability": "stop_waiting_only", + "cancel_propagation": "stop_waiting_only", + } diff --git a/backend/tests/test_agent_runtime_checkpoint_side_effects.py b/backend/tests/test_agent_runtime_checkpoint_side_effects.py index 17c21caea..85db748ea 100644 --- a/backend/tests/test_agent_runtime_checkpoint_side_effects.py +++ b/backend/tests/test_agent_runtime_checkpoint_side_effects.py @@ -2,9 +2,9 @@ from __future__ import annotations +import uuid from dataclasses import replace from unittest.mock import AsyncMock, patch -import uuid import pytest from sqlalchemy.dialects import postgresql @@ -281,14 +281,22 @@ async def test_checkpoint_projects_replayable_tool_activity_with_redacted_argume }, } ], + "provider_call_ids": {"call-1": "provider-call-1"}, }, { "id": "tool-result-1", "role": "tool", "tool_call_id": "call-1", "name": "read_file", - "content": "contents", - "execution_status": "succeeded", + "content": "$.path must have type string.", + "execution_status": "failed", + "error_code": "tool_arguments_invalid", + "model_action": "repair_arguments", + "side_effect_state": "none", + "safe_remediation": "Correct $.path and call the Tool again.", + "execution_id": "execution-1", + "provider_call_id": "provider-call-1", + "contract_version": "runtime:read_file:v1", }, ], }, @@ -316,8 +324,21 @@ async def test_checkpoint_projects_replayable_tool_activity_with_redacted_argume "tool_call", ] assert activities[1]["status"] == "running" + assert activities[1]["call_instance_id"] == "call-1" + assert activities[1]["provider_call_id"] == "provider-call-1" assert activities[2]["status"] == "done" - assert activities[2]["result"] == "contents" + assert activities[2]["call_instance_id"] == "call-1" + assert activities[2]["provider_call_id"] == "provider-call-1" + assert activities[2]["execution_id"] == "execution-1" + assert activities[2]["contract_version"] == "runtime:read_file:v1" + assert activities[2]["result"] == "$.path must have type string." + assert activities[2]["execution_status"] == "failed" + assert activities[2]["error_code"] == "tool_arguments_invalid" + assert activities[2]["model_action"] == "repair_arguments" + assert activities[2]["side_effect_state"] == "none" + assert activities[2]["safe_remediation"] == ( + "Correct $.path and call the Tool again." + ) assert activities[1]["args"]["api_key"] == "[REDACTED]" diff --git a/backend/tests/test_agent_runtime_contracts.py b/backend/tests/test_agent_runtime_contracts.py index 194fb241e..fd6326ca7 100644 --- a/backend/tests/test_agent_runtime_contracts.py +++ b/backend/tests/test_agent_runtime_contracts.py @@ -1,17 +1,19 @@ +import uuid from dataclasses import FrozenInstanceError, fields from datetime import UTC, datetime -import uuid import pytest from app.services.agent_runtime.contracts import ( CancelRunCommand, + ResumeRunCommand, RunHandle, - RunView, RuntimeEvent, - ResumeRunCommand, + RunView, StartRunCommand, ) +from app.services.agent_runtime.state import RuntimeLifecycle +from app.services.agent_runtime.tool_contracts import parse_step_tool_context def test_execution_commands_cannot_carry_product_projection_fields() -> None: @@ -93,3 +95,17 @@ def test_run_view_and_runtime_event_are_query_only_values() -> None: assert view.execution_status == "running" assert event.payload == {"status": "running"} + + +def test_legacy_runtime_lifecycle_may_omit_step_tool_context() -> None: + lifecycle: RuntimeLifecycle = { + "status": "running", + "next_route": "tool", + "pending_tool_calls": [], + } + + assert "step_tool_context" not in lifecycle + assert parse_step_tool_context( + lifecycle.get("step_tool_context"), + allow_legacy_missing=True, + ) is None diff --git a/backend/tests/test_agent_runtime_model_step_service.py b/backend/tests/test_agent_runtime_model_step_service.py index 483c61a8e..767659f9d 100644 --- a/backend/tests/test_agent_runtime_model_step_service.py +++ b/backend/tests/test_agent_runtime_model_step_service.py @@ -1,37 +1,41 @@ """Runtime model-step adapter tests.""" import base64 +import json +import uuid from contextlib import asynccontextmanager from dataclasses import replace from datetime import UTC, datetime -import json from unittest.mock import AsyncMock, patch -import uuid import pytest +from langchain_core.messages import convert_to_messages from app.models.agent import Agent from app.models.llm import LLMModel from app.services.agent_runtime.context_builder import RuntimeContextBuild -from app.services.agent_runtime.group_handoff import GroupAgentHandoffIntent -from app.services.agent_runtime.group_handoff import GroupAgentHandoffError -from app.services.agent_runtime.model_step_service import RuntimeModelStepService -from app.services.agent_runtime.model_step_service import RuntimeModelCallError -from app.services.agent_runtime.model_step_service import _group_mention_mismatches -from app.services.agent_runtime.model_step_service import _message_token_counter -from app.services.agent_runtime.model_step_service import _prompt_messages -from app.services.agent_runtime.model_step_service import _visible_mention_names +from app.services.agent_runtime.group_handoff import GroupAgentHandoffError, GroupAgentHandoffIntent +from app.services.agent_runtime.model_step_service import ( + RuntimeModelCallError, + RuntimeModelStepService, + _group_mention_mismatches, + _message_token_counter, + _prompt_messages, + _tool_repair_reset_reason, + _visible_mention_names, +) from app.services.agent_runtime.state import ( RunInputSnapshots, RunRegistrySnapshot, RuntimeContext, RuntimeGraphState, + runtime_message_to_json, ) -from app.services.llm.single_step import LLMCompletionStep +from app.services.agent_runtime.tool_contracts import parse_step_tool_context from app.services.llm.finish import FINISH_PROTOCOL_REMINDER +from app.services.llm.single_step import LLMCompletionStep from app.services.token_tracker import TokenUsage - _TINY_PNG_BASE64 = ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/" "x8AAusB9Wl2ZQAAAABJRU5ErkJggg==" @@ -289,6 +293,64 @@ def test_prompt_messages_compatibly_parse_legacy_image_checkpoint() -> None: ] +def test_explicit_user_correction_is_the_only_tool_repair_reset_boundary() -> None: + state = _state(uuid.uuid4(), _model(uuid.uuid4()), _agent(uuid.uuid4())) + state["lifecycle"]["tool_repair_reset"] = { + "reason": "explicit_user_correction" + } + assert _tool_repair_reset_reason(state) == "explicit_user_correction" + + state["lifecycle"]["tool_repair_reset"] = {"reason": "provider_retry"} + assert _tool_repair_reset_reason(state) is None + + +def test_prompt_messages_restore_provider_tool_call_pairing() -> None: + build = _build( + current_run={"run_id": str(uuid.uuid4()), "goal": "Read"}, + recent_session_messages_snapshot=(), + recent_thread_messages=( + { + "id": "assistant-1", + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-instance-1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":"README.md"}', + }, + } + ], + "provider_call_ids": { + "call-instance-1": "provider-call-1", + }, + }, + { + "id": "tool-result-1", + "role": "tool", + "tool_call_id": "call-instance-1", + "content": "contents", + }, + ), + initial_input={"input_content": "Continue"}, + ) + + messages = _prompt_messages( + static_prompt="Static", + dynamic_prompt="Dynamic", + build=build, + ) + + assistant = next(message for message in messages if message.role == "assistant") + tool = next(message for message in messages if message.role == "tool") + assert assistant.tool_calls is not None + assert assistant.tool_calls[0]["id"] == "provider-call-1" + assert "provider_call_id" not in assistant.tool_calls[0] + assert tool.tool_call_id == "provider-call-1" + + def test_message_budget_does_not_treat_large_base64_as_text_tokens() -> None: padded_png = base64.b64encode( base64.b64decode(_TINY_PNG_BASE64) + b"x" * (1024 * 1024) @@ -444,8 +506,37 @@ async def complete(model_arg, messages, **kwargs): assert result.intent == "tool_calls" assert result.assistant_message is not None assert result.assistant_message["id"] == expected_message_id - assert result.assistant_message["tool_calls"] == list(result.tool_calls) + assert result.assistant_message["tool_calls"][0]["id"] == ( + result.tool_calls[0]["id"] + ) + assert "provider_call_id" not in result.assistant_message["tool_calls"][0] assert result.assistant_message["reasoning_content"] == "inspect" + tool_context = parse_step_tool_context(result.step_tool_context) + assert tool_context is not None + assert tool_context.assistant_message_id == expected_message_id + assert tool_context.model_step == 1 + expected_call_instance_id = str( + uuid.uuid5( + uuid.UUID(run_id), + f"call-instance:{expected_message_id}:0", + ) + ) + assert tool_context.accepted_calls[0].call_instance_id == ( + expected_call_instance_id + ) + assert tool_context.accepted_calls[0].provider_call_id == "call-1" + assert result.tool_calls[0]["id"] == expected_call_instance_id + assert result.tool_calls[0]["provider_call_id"] == "call-1" + checkpoint_message = runtime_message_to_json( + convert_to_messages([result.assistant_message])[0] + ) + assert checkpoint_message["provider_call_ids"] == { + expected_call_instance_id: "call-1" + } + assert tool_context.accepted_calls[0].entry.tool_name == "read_file" + assert tool_context.accepted_calls[0].entry.binding.handler_key == "read_file" + assert tool_context.accepted_calls[0].entry.effect == "read" + assert tool_context.accepted_calls[0].entry.retry_policy == "safe" assert len(calls) == 1 tool_names = {tool["function"]["name"] for tool in calls[0][2]["tools"]} assert tool_names == {"read_file", "wait"} @@ -460,7 +551,54 @@ async def complete(model_arg, messages, **kwargs): @pytest.mark.asyncio -async def test_invalid_write_file_arguments_request_three_protocol_repairs() -> None: +async def test_fallback_tool_proposal_freezes_the_actual_fallback_workset() -> None: + tenant_id = uuid.uuid4() + model = _model(tenant_id) + fallback = _model(tenant_id) + fallback.model = "fallback-model" + agent = _agent(tenant_id) + agent.fallback_model_id = fallback.id + state = _state(tenant_id, model, agent) + + async def complete(model_arg, _messages, **_kwargs): + if model_arg.id == model.id: + raise TimeoutError("primary provider timeout") + return LLMCompletionStep( + content="", + tool_calls=( + { + "id": "fallback-call-1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":"notes.md"}', + }, + }, + ), + reasoning_content=None, + retry_instruction=None, + usage=TokenUsage(total_tokens=20), + ) + + result = await _failover_service( + model, + fallback, + agent, + _ContextBuilder(_build()), + complete, + ).complete_once(state, _context(state)) + + tool_context = parse_step_tool_context(result.step_tool_context) + assert result.intent == "tool_calls" + assert tool_context is not None + assert tool_context.accepted_calls[0].call_instance_id != "fallback-call-1" + assert tool_context.accepted_calls[0].provider_call_id == "fallback-call-1" + assert result.assistant_message is not None + assert result.assistant_message["runtime_model_id"] == str(fallback.id) + + +@pytest.mark.asyncio +async def test_invalid_write_file_arguments_request_ten_protocol_repairs() -> None: tenant_id = uuid.uuid4() model = _model(tenant_id) agent = _agent(tenant_id) diff --git a/backend/tests/test_agent_runtime_node_executor.py b/backend/tests/test_agent_runtime_node_executor.py index 9c112d786..19fd4aa3a 100644 --- a/backend/tests/test_agent_runtime_node_executor.py +++ b/backend/tests/test_agent_runtime_node_executor.py @@ -2,13 +2,13 @@ from __future__ import annotations +import uuid from collections import deque from typing import cast -import uuid +import pytest from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import Command -import pytest from app.config import Settings from app.services.agent_runtime.checkpointer import runtime_thread_config @@ -127,6 +127,35 @@ async def execute_pending( return self.result +class RepairFailingToolService: + def __init__(self) -> None: + self.calls: list[tuple[JsonObject, ...]] = [] + + async def execute_pending( + self, + state: RuntimeGraphState, + context: RuntimeContext, + tool_calls: tuple[JsonObject, ...], + ) -> ToolStepResult: + del state, context + self.calls.append(tool_calls) + call = tool_calls[0] + return ToolStepResult( + messages=( + { + "role": "tool", + "tool_call_id": str(call["id"]), + "name": "read_file", + "content": "$.path is required.", + "execution_status": "failed", + "error_code": "tool_arguments_invalid", + "model_action": "repair_arguments", + "side_effect_state": "none", + }, + ) + ) + + class PerCallRetryingToolService: """Fail each receipt twice so LangGraph must budget retries per call.""" @@ -664,6 +693,47 @@ async def test_tool_batch_is_executed_before_the_next_model_step() -> None: assert messages[1]["tool_call_id"] == "call-1" +@pytest.mark.asyncio +async def test_model_node_checkpoints_tool_context_with_pending_calls_atomically() -> None: + run_id = uuid.uuid4() + tool_call: JsonObject = { + "id": "call-context-1", + "name": "lookup", + "arguments": {"query": "answer"}, + } + step_context: JsonObject = { + "version": 1, + "assistant_message_id": "assistant-context-1", + "model_step": 1, + "workset_version": "sha256:test", + "accepted_calls": [], + } + executor = _executor( + ModelService( + ModelStepResult( + intent="tool_calls", + assistant_message={ + "id": "assistant-context-1", + "role": "assistant", + "tool_calls": [tool_call], + }, + tool_calls=(tool_call,), + step_tool_context=step_context, + ) + ) + ) + state = _state(run_id) + + update = await executor.execute( + "model", + state, + _context(run_id, executor, "command-context"), + ) + + assert update["lifecycle"]["pending_tool_calls"] == [tool_call] + assert update["lifecycle"]["step_tool_context"] == step_context + + @pytest.mark.asyncio async def test_each_tool_call_gets_an_independent_langgraph_retry_budget( monkeypatch, @@ -713,6 +783,74 @@ async def no_sleep(_seconds: float) -> None: ] +@pytest.mark.asyncio +async def test_tenth_same_tool_failure_pauses_before_next_model_call() -> None: + run_id = uuid.uuid4() + proposals = tuple( + ModelStepResult( + intent="tool_calls", + assistant_message={ + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": f"call-{index}", + "name": "read_file", + "arguments": {}, + } + ], + }, + tool_calls=( + { + "id": f"call-{index}", + "name": "read_file", + "arguments": {}, + }, + ), + ) + for index in range(1, 12) + ) + model = ModelService(*proposals) + tools = RepairFailingToolService() + executor = DeterministicRuntimeNodeExecutor( + cancel_source=CancelSource(), + model_service=model, + tool_service=tools, + finalizer=Finalizer(), + ) + + result = await _invoke(run_id, executor) + + lifecycle = result["lifecycle"] + assert lifecycle["status"] == "waiting_user" + assert lifecycle["reason"] == ( + "tool_repair_same_fingerprint_limit_reached" + ) + assert lifecycle["model_step_count"] == 10 + repair_episode = lifecycle["tool_repair_episodes"]["by_tool"]["read_file"] + assert repair_episode["total_failures"] == 10 + assert repair_episode["same_fingerprint_failures"] == 10 + assert model.calls == 10 + assert len(tools.calls) == 10 + + resumed = await executor.execute( + "wait", + cast(RuntimeGraphState, result), + _context(run_id, executor, "command-user-correction"), + resume_value={ + "resume_type": "user_input", + "payload": {"content": "Use README.md as the path."}, + }, + ) + assert resumed["lifecycle"]["tool_repair_episodes"] == { + "version": 1, + "by_tool": {}, + } + assert resumed["lifecycle"]["tool_repair_reset"]["reason"] == ( + "explicit_user_correction" + ) + + @pytest.mark.asyncio async def test_duplicate_tool_call_ids_fail_before_any_provider_execution() -> None: run_id = uuid.uuid4() @@ -1213,15 +1351,16 @@ async def test_empty_output_is_repaired_once_then_fails_explicitly() -> None: @pytest.mark.asyncio @pytest.mark.parametrize( - ("repair_code", "instruction"), + ("repair_code", "instruction", "repair_limit"), [ - ("invalid_finish", "Retry finish with valid content."), - ("invalid_tool_call", "Retry with valid JSON tool arguments."), + ("invalid_finish", "Retry finish with valid content.", 1), + ("invalid_tool_call", "Retry with valid JSON tool arguments.", 10), ], ) async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( repair_code: str, instruction: str, + repair_limit: int, ) -> None: run_id = uuid.uuid4() repair = ModelStepResult( @@ -1230,7 +1369,7 @@ async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( repair_instruction=instruction, repair_code=repair_code, ) - model = ModelService(repair, repair) + model = ModelService(*([repair] * (repair_limit + 1))) executor = _executor(model) result = await _invoke(run_id, executor, model_turn_limit=50) @@ -1239,13 +1378,13 @@ async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( assert lifecycle["status"] == "failed" assert lifecycle["reason"] == "model_tool_protocol_violation" assert lifecycle["error"]["code"] == "model_tool_protocol_violation" - assert lifecycle["model_protocol_repairs"] == {repair_code: 1} - assert lifecycle["model_step_count"] == 2 - assert model.calls == 2 + assert lifecycle["model_protocol_repairs"] == {repair_code: repair_limit} + assert lifecycle["model_step_count"] == repair_limit + 1 + assert model.calls == repair_limit + 1 @pytest.mark.asyncio -async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() -> None: +async def test_write_file_protocol_repair_uses_ten_attempts_then_guides_user() -> None: run_id = uuid.uuid4() repair = ModelStepResult( intent="text", @@ -1254,7 +1393,7 @@ async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() repair_code="invalid_tool_call", repair_tool_name="write_file", ) - model = ModelService(repair, repair, repair, repair) + model = ModelService(*([repair] * 11)) executor = _executor(model) result = await _invoke(run_id, executor, model_turn_limit=50) @@ -1270,14 +1409,14 @@ async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() ), } assert lifecycle["model_protocol_repairs"] == { - "invalid_tool_call:write_file": 3, + "invalid_tool_call:write_file": 10, } - assert lifecycle["model_step_count"] == 4 - assert model.calls == 4 + assert lifecycle["model_step_count"] == 11 + assert model.calls == 11 @pytest.mark.asyncio -async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: +async def test_write_file_protocol_can_recover_on_the_tenth_repair() -> None: run_id = uuid.uuid4() repair = ModelStepResult( intent="text", @@ -1286,9 +1425,7 @@ async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: repair_tool_name="write_file", ) model = ModelService( - repair, - repair, - repair, + *([repair] * 10), ModelStepResult(intent="finish", finish_content="Recovered"), ) executor = _executor(model) @@ -1297,9 +1434,9 @@ async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: assert result["lifecycle"]["status"] == "completed" assert result["lifecycle"]["model_protocol_repairs"] == { - "invalid_tool_call:write_file": 3, + "invalid_tool_call:write_file": 10, } - assert model.calls == 4 + assert model.calls == 11 @pytest.mark.asyncio @@ -1393,7 +1530,7 @@ async def test_verification_repairs_are_bounded() -> None: ) verifier = Verifier( VerificationResult(outcome="repair", reason="add evidence"), - VerificationResult(outcome="repair", reason="still incomplete"), + VerificationResult(outcome="repair", reason="add evidence"), ) executor = _executor( model, @@ -1414,3 +1551,46 @@ async def test_verification_repairs_are_bounded() -> None: assert messages[-1]["role"] == "user" assert messages[-1]["content"] == "add evidence" assert verifier.calls == ["first", "second"] + + +@pytest.mark.asyncio +async def test_new_verifier_issue_starts_a_fresh_episode() -> None: + run_id = uuid.uuid4() + model = ModelService( + ModelStepResult(intent="finish", finish_content="first"), + ModelStepResult(intent="finish", finish_content="second"), + ModelStepResult(intent="finish", finish_content="third"), + ) + verifier = Verifier( + VerificationResult( + outcome="repair", + reason="add evidence", + details={"code": "missing_evidence"}, + ), + VerificationResult( + outcome="repair", + reason="fix citation", + details={"code": "bad_citation"}, + ), + VerificationResult( + outcome="repair", + reason="fix citation", + details={"code": "bad_citation"}, + ), + ) + executor = _executor( + model, + verifier=verifier, + max_verification_repairs=1, + ) + + result = await _invoke(run_id, executor, model_turn_limit=3) + + lifecycle = result["lifecycle"] + assert lifecycle["status"] == "failed" + assert lifecycle["reason"] == "verification_repair_limit_reached" + assert lifecycle["verification_attempt_count"] == 2 + assert lifecycle["verification_repair_episode"]["issue_code"] == ( + "bad_citation" + ) + assert model.calls == 3 diff --git a/backend/tests/test_agent_runtime_tool_contracts.py b/backend/tests/test_agent_runtime_tool_contracts.py new file mode 100644 index 000000000..548fbe093 --- /dev/null +++ b/backend/tests/test_agent_runtime_tool_contracts.py @@ -0,0 +1,132 @@ +"""Checkpoint-safe Tool Runtime contract tests.""" + +import pytest + +from app.services.agent_runtime.tool_contracts import ( + AcceptedToolCall, + StepToolContext, + ToolContractError, + ToolExecutionBinding, + ToolWorksetEntry, + parse_step_tool_context, + workset_version, +) + + +def _entry() -> ToolWorksetEntry: + return ToolWorksetEntry( + tool_name="read_document", + contract_version="builtin:read_document:v1", + parameters_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + }, + binding=ToolExecutionBinding( + kind="builtin", + handler_key="read_document", + ), + effect="read", + retry_policy="safe", + authorization_policy="runtime_default", + deadline_policy="runtime_default", + recovery_policy="safe_read", + ) + + +def test_step_tool_context_round_trips_three_distinct_identities() -> None: + entry = _entry() + accepted = AcceptedToolCall( + call_instance_id="call-instance-1", + provider_call_id="provider-call-7", + entry=entry, + ) + context = StepToolContext( + assistant_message_id="assistant-1", + model_step=3, + workset_version=workset_version((entry,)), + accepted_calls=(accepted,), + ) + + restored = parse_step_tool_context(context.to_json()) + + assert restored == context + assert restored.accepted_calls[0].call_instance_id == "call-instance-1" + assert restored.accepted_calls[0].provider_call_id == "provider-call-7" + assert "execution_id" not in restored.to_json()["accepted_calls"][0] + + +def test_legacy_checkpoint_may_omit_step_tool_context() -> None: + assert parse_step_tool_context(None, allow_legacy_missing=True) is None + + with pytest.raises(ToolContractError, match="missing"): + parse_step_tool_context(None, allow_legacy_missing=False) + + +def test_step_tool_context_rejects_unknown_versions_and_secret_material() -> None: + payload = StepToolContext( + assistant_message_id="assistant-1", + model_step=1, + workset_version=workset_version((_entry(),)), + accepted_calls=( + AcceptedToolCall( + call_instance_id="call-1", + provider_call_id=None, + entry=_entry(), + ), + ), + ).to_json() + payload["version"] = 99 + + with pytest.raises(ToolContractError, match="version"): + parse_step_tool_context(payload) + + payload["version"] = 1 + accepted = payload["accepted_calls"][0] + accepted["binding"]["target"] = {"api_key": "plain-secret"} + + with pytest.raises(ToolContractError, match="secret"): + parse_step_tool_context(payload) + + +def test_workset_version_is_canonical_and_order_independent() -> None: + first = _entry() + second = ToolWorksetEntry( + tool_name="write_file", + contract_version="builtin:write_file:v1", + parameters_schema={"type": "object", "properties": {}}, + binding=ToolExecutionBinding(kind="builtin", handler_key="write_file"), + effect="write", + retry_policy="conditional", + ) + + assert workset_version((first, second)) == workset_version((second, first)) + + +def test_context_rejects_duplicate_call_instances_or_tool_mismatch() -> None: + entry = _entry() + call = AcceptedToolCall( + call_instance_id="call-1", + provider_call_id="provider-1", + entry=entry, + ) + + with pytest.raises(ToolContractError, match="duplicate"): + StepToolContext( + assistant_message_id="assistant-1", + model_step=1, + workset_version=workset_version((entry,)), + accepted_calls=(call, call), + ) + + payload = StepToolContext( + assistant_message_id="assistant-1", + model_step=1, + workset_version=workset_version((entry,)), + accepted_calls=(call,), + ).to_json() + payload["accepted_calls"][0]["tool_name"] = "write_file" + + with pytest.raises(ToolContractError, match="binding"): + parse_step_tool_context(payload) diff --git a/backend/tests/test_agent_runtime_tool_execution_migration.py b/backend/tests/test_agent_runtime_tool_execution_migration.py new file mode 100644 index 000000000..9e4444bf7 --- /dev/null +++ b/backend/tests/test_agent_runtime_tool_execution_migration.py @@ -0,0 +1,97 @@ +"""Migration contract for Runtime Tool identity separation.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] + / "alembic" + / "versions" + / "v1_11_3_f062_tool_execution_identity.py" +) + + +def _load_migration(): + spec = importlib.util.spec_from_file_location( + "tool_execution_identity_migration", + MIGRATION_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_revision_extends_the_current_single_head() -> None: + migration = _load_migration() + + assert migration.revision == "f062_tool_execution_identity" + assert migration.down_revision == "f061_enterprise_info_tenant_id" + + +def test_upgrade_adds_only_missing_nullable_identity_columns(monkeypatch) -> None: + migration = _load_migration() + calls = [] + monkeypatch.setattr( + migration, + "_column_names", + lambda **_kwargs: {"provider_call_id"}, + ) + monkeypatch.setattr( + migration.op, + "add_column", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + migration.upgrade() + + assert len(calls) == 1 + table_name, column = calls[0][0] + assert table_name == "agent_tool_executions" + assert column.name == "contract_version" + assert column.nullable is True + assert str(column.type) == "VARCHAR(255)" + + +def test_upgrade_is_compatible_with_rows_created_before_the_migration( + monkeypatch, +) -> None: + migration = _load_migration() + monkeypatch.setattr( + migration, + "_column_names", + lambda **_kwargs: {"provider_call_id", "contract_version"}, + ) + monkeypatch.setattr( + migration.op, + "add_column", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError(f"unexpected add_column: {args}, {kwargs}") + ), + ) + + migration.upgrade() + + +def test_downgrade_drops_both_identity_columns_in_reverse_order(monkeypatch) -> None: + migration = _load_migration() + calls = [] + monkeypatch.setattr( + migration, + "_column_names", + lambda **_kwargs: {"provider_call_id", "contract_version"}, + ) + monkeypatch.setattr( + migration.op, + "drop_column", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + migration.downgrade() + + assert [args for args, _ in calls] == [ + ("agent_tool_executions", "contract_version"), + ("agent_tool_executions", "provider_call_id"), + ] diff --git a/backend/tests/test_agent_runtime_tool_outcome_contract.py b/backend/tests/test_agent_runtime_tool_outcome_contract.py index 03c559625..728ebb8d1 100644 --- a/backend/tests/test_agent_runtime_tool_outcome_contract.py +++ b/backend/tests/test_agent_runtime_tool_outcome_contract.py @@ -2,17 +2,17 @@ from __future__ import annotations -from contextlib import asynccontextmanager -from collections import deque -from datetime import UTC, datetime, timedelta import hashlib import json import uuid +from collections import deque +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta import pytest -from app.services import agent_tools from app.models.agent_tool_execution import AgentToolExecution +from app.services import agent_tools from app.services.agent_runtime.state import ( RunInputSnapshots, RuntimeContext, @@ -20,6 +20,7 @@ ) from app.services.agent_runtime.tool_execution import ( ToolExecutionOutcome, + execution_outcome, normalize_tool_outcome, sanitize_tool_arguments, ) @@ -291,6 +292,86 @@ def test_outcome_normalizer_replaces_controls_redacts_credentials_and_caps_utf8_ assert normalized.metadata["content_hash"] +def test_failure_feedback_fields_are_sanitized_bounded_and_replayable() -> None: + normalized, archived_body = normalize_tool_outcome( + ToolExecutionOutcome( + status="failed", + result_summary="Argument validation failed.", + result_ref=None, + error_code="tool_arguments_invalid", + model_action="repair_arguments", + side_effect_state="none", + safe_remediation=( + "Correct $.path; Authorization: Bearer must-not-survive\x00" + + "界" * 300 + ), + ), + effect="read", + retry_policy="safe", + inline_max_bytes=1024, + ) + + assert archived_body is None + assert normalized.model_action == "repair_arguments" + assert normalized.side_effect_state == "none" + assert normalized.safe_remediation is not None + assert "must-not-survive" not in normalized.safe_remediation + assert "\x00" not in normalized.safe_remediation + assert len(normalized.safe_remediation.encode("utf-8")) <= 512 + assert normalized.metadata["model_action"] == "repair_arguments" + assert normalized.metadata["side_effect_state"] == "none" + assert normalized.metadata["safe_remediation"] == normalized.safe_remediation + + execution = _execution( + tenant_id=uuid.uuid4(), + run_id=uuid.uuid4(), + status="failed", + ) + execution.result_summary = normalized.result_summary + execution.result_ref = normalized.result_ref + execution.result_metadata = normalized.metadata + replayed = execution_outcome(execution) + + assert replayed.model_action == normalized.model_action + assert replayed.side_effect_state == normalized.side_effect_state + assert replayed.safe_remediation == normalized.safe_remediation + + +@pytest.mark.parametrize( + ("error_code", "event_key"), + ( + ("tool_deadline_outcome_unknown", "deadline_exceeded"), + ("tool_cancelled_outcome_unknown", "cancel_requested"), + ), +) +def test_possible_write_after_deadline_or_cancel_is_unknown_and_not_replayable( + error_code: str, + event_key: str, +) -> None: + normalized, archived_body = normalize_tool_outcome( + ToolExecutionOutcome( + status="unknown", + result_summary="External write may have happened; reconcile first.", + result_ref=None, + error_code=error_code, + retryable=False, + model_action="reconcile", + side_effect_state="unknown", + metadata={event_key: True}, + ), + effect="external_write", + retry_policy="never", + inline_max_bytes=1024, + ) + + assert archived_body is None + assert normalized.status == "unknown" + assert normalized.retryable is False + assert normalized.model_action == "reconcile" + assert normalized.side_effect_state == "unknown" + assert normalized.metadata[event_key] is True + + def test_outcome_normalizer_preserves_bounded_email_provider_receipt() -> None: normalized, archived_body = normalize_tool_outcome( ToolExecutionOutcome( diff --git a/backend/tests/test_agent_runtime_tool_repair_budget.py b/backend/tests/test_agent_runtime_tool_repair_budget.py new file mode 100644 index 000000000..2e9916216 --- /dev/null +++ b/backend/tests/test_agent_runtime_tool_repair_budget.py @@ -0,0 +1,138 @@ +"""Pure Tool repair episode transition contracts.""" + +from app.services.agent_runtime.tool_repair_budget import ( + SAME_FINGERPRINT_FAILURE_LIMIT, + TOOL_EPISODE_FAILURE_LIMIT, + apply_tool_result, + reset_tool_repair_episodes, +) + + +def _failure( + *, + tool_name: str = "read_file", + content: str = "$.path is required.", +) -> dict: + return { + "role": "tool", + "tool_call_id": "call-1", + "name": tool_name, + "content": content, + "execution_status": "failed", + "error_code": "tool_arguments_invalid", + "model_action": "repair_arguments", + "side_effect_state": "none", + } + + +def _episode(state: dict, tool_name: str = "read_file") -> dict: + return state["by_tool"][tool_name] + + +def test_tenth_consecutive_fingerprint_pauses_without_off_by_one() -> None: + state: dict = {} + transition = None + for model_step in range(1, SAME_FINGERPRINT_FAILURE_LIMIT + 1): + transition = apply_tool_result( + state, + _failure(), + model_step=model_step, + ) + state = transition.episodes + assert transition.pause_reason is ( + None + if model_step < SAME_FINGERPRINT_FAILURE_LIMIT + else "tool_repair_same_fingerprint_limit_reached" + ) + + assert transition is not None + assert _episode(state)["same_fingerprint_failures"] == 10 + assert _episode(state)["total_failures"] == 10 + + +def test_tenth_tool_failure_pauses_even_when_fingerprint_changes() -> None: + state: dict = {} + transition = None + for model_step in range(1, TOOL_EPISODE_FAILURE_LIMIT + 1): + transition = apply_tool_result( + state, + _failure(content=f"problem-{model_step}"), + model_step=model_step, + ) + state = transition.episodes + + assert transition is not None + assert transition.pause_reason == "tool_repair_episode_limit_reached" + assert _episode(state)["total_failures"] == 10 + assert _episode(state)["same_fingerprint_failures"] == 1 + + +def test_fingerprint_change_only_resets_consecutive_counter() -> None: + first = apply_tool_result({}, _failure(content="first"), model_step=1) + second = apply_tool_result( + first.episodes, + _failure(content="second"), + model_step=2, + ) + + assert _episode(second.episodes)["total_failures"] == 2 + assert _episode(second.episodes)["same_fingerprint_failures"] == 1 + + +def test_same_tool_success_and_explicit_user_correction_reset_episode() -> None: + failed = apply_tool_result({}, _failure(), model_step=1) + unrelated_success = apply_tool_result( + failed.episodes, + { + "role": "tool", + "tool_call_id": "call-2", + "name": "list_files", + "execution_status": "succeeded", + }, + model_step=2, + ) + assert "read_file" in unrelated_success.episodes["by_tool"] + + same_tool_success = apply_tool_result( + unrelated_success.episodes, + { + "role": "tool", + "tool_call_id": "call-3", + "name": "read_file", + "execution_status": "succeeded", + }, + model_step=3, + ) + assert "read_file" not in same_tool_success.episodes["by_tool"] + + failed_again = apply_tool_result( + same_tool_success.episodes, + _failure(), + model_step=4, + ) + assert reset_tool_repair_episodes(failed_again.episodes) == { + "version": 1, + "by_tool": {}, + } + + +def test_retry_wait_pending_cancel_unknown_and_nonrepairable_failures_do_not_count() -> None: + excluded = ( + {**_failure(), "execution_status": "pending", "model_action": "wait"}, + { + **_failure(), + "execution_status": "unknown", + "model_action": "reconcile", + "side_effect_state": "unknown", + }, + {**_failure(), "model_action": "ask_user"}, + {**_failure(), "side_effect_state": "possible"}, + ) + state: dict = {} + for model_step, message in enumerate(excluded, start=1): + transition = apply_tool_result(state, message, model_step=model_step) + state = transition.episodes + assert transition.counted is False + assert transition.pause_reason is None + + assert state == {"version": 1, "by_tool": {}} diff --git a/backend/tests/test_agent_runtime_tool_step_service.py b/backend/tests/test_agent_runtime_tool_step_service.py index a8416377d..a63e1f26b 100644 --- a/backend/tests/test_agent_runtime_tool_step_service.py +++ b/backend/tests/test_agent_runtime_tool_step_service.py @@ -1,8 +1,9 @@ """Receipt-backed Runtime tool-step tests.""" -from contextlib import asynccontextmanager -from collections import deque +import asyncio import uuid +from collections import deque +from contextlib import asynccontextmanager import pytest @@ -17,6 +18,13 @@ RuntimeContext, RuntimeGraphState, ) +from app.services.agent_runtime.tool_contracts import ( + AcceptedToolCall, + StepToolContext, + ToolExecutionBinding, + ToolWorksetEntry, + workset_version, +) from app.services.agent_runtime.tool_execution import ( RetryableToolNodeError, ToolExecutionOutcome, @@ -220,6 +228,120 @@ async def _tools(agent_id: uuid.UUID) -> list[dict]: ] +def _with_step_tool_context( + state: RuntimeGraphState, + call: dict, + *, + context_tool_name: str | None = None, + parameters_schema: dict | None = None, +) -> None: + call_id = str(call["id"]) + tool_name = context_tool_name or str(call["function"]["name"]) + policy = tool_step_service._policy(tool_name) + entry = ToolWorksetEntry( + tool_name=tool_name, + contract_version=f"runtime:{tool_name}:v1", + parameters_schema=parameters_schema + or {"type": "object", "properties": {}}, + binding=ToolExecutionBinding(kind="builtin", handler_key=tool_name), + effect=policy.side_effect_classification, # type: ignore[arg-type] + retry_policy=policy.retry_policy, # type: ignore[arg-type] + ) + context = StepToolContext( + assistant_message_id="assistant-message-1", + model_step=1, + workset_version=workset_version((entry,)), + accepted_calls=( + AcceptedToolCall( + call_instance_id=call_id, + provider_call_id=call_id, + entry=entry, + ), + ), + ) + state["lifecycle"]["step_tool_context"] = context.to_json() + + +@pytest.mark.asyncio +async def test_schema_failure_returns_one_repair_result_before_receipt( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = { + "id": "invalid-arguments-1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":42,"credential":"must-not-echo"}', + }, + } + state = _state(tenant_id, agent, (call,)) + _with_step_tool_context( + state, + call, + parameters_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + }, + ) + + async def forbidden(*args, **kwargs): + raise AssertionError(f"invalid call crossed the Receipt gate: {args}, {kwargs}") + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", forbidden) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None), + tool_provider=forbidden, + tool_executor=forbidden, + ) + + result = await service.execute_pending(state, _context(state), (call,)) + + assert result.error is None + assert len(result.messages) == 1 + message = result.messages[0] + assert message["tool_call_id"] == "invalid-arguments-1" + assert message["execution_status"] == "failed" + assert message["error_code"] == "tool_arguments_invalid" + assert message["model_action"] == "repair_arguments" + assert message["side_effect_state"] == "none" + assert "$.path must have type string" in str(message["content"]) + assert "$.credential is not an accepted argument" in str(message["content"]) + assert "must-not-echo" not in str(message) + + +@pytest.mark.parametrize( + ("status", "model_action", "side_effect_state"), + ( + ("pending", "wait", "possible"), + ("unknown", "reconcile", "unknown"), + ), +) +def test_control_outcomes_keep_distinct_model_visible_status( + status: str, + model_action: str, + side_effect_state: str, +) -> None: + message = tool_step_service._result_message( + run_id=uuid.uuid4(), + call_id="call-1", + tool_name="write_file", + outcome=ToolExecutionOutcome( + status=status, # type: ignore[arg-type] + result_summary="control state", + result_ref=None, + ), + ) + + assert message["execution_status"] == status + assert message["model_action"] == model_action + assert message["side_effect_state"] == side_effect_state + + def _execution( tenant_id: uuid.UUID, run_id: uuid.UUID, @@ -470,10 +592,254 @@ async def mark(db, **kwargs): "role": "tool", "tool_call_id": "call-1", "name": "read_file", - "content": "file contents", - "execution_status": "succeeded", - "result_ref": None, - }, + "content": "file contents", + "execution_status": "succeeded", + "result_ref": None, + "model_action": "continue", + "side_effect_state": "confirmed", + "execution_id": str(execution.id), + "call_instance_id": "call-1", + }, + ) + + +@pytest.mark.asyncio +async def test_new_checkpoint_executes_frozen_binding_without_tool_provider( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _call("call-frozen", "read_file") + state = _state(tenant_id, agent, (call,)) + _with_step_tool_context(state, call) + context = _context(state) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "call-frozen", + "read_file", + ) + + async def forbidden_provider(_agent_id: uuid.UUID) -> list[dict]: + raise AssertionError("new checkpoint Tool Step rebuilt the Workset") + + async def reserve(db, **kwargs): + del db, kwargs + return _reservation(execution) + + async def execute(*args, **kwargs): + del args, kwargs + return ToolExecutionOutcome( + status="succeeded", + result_summary="frozen result", + result_ref=None, + ) + + async def mark(db, **kwargs): + del db, kwargs + execution.status = "succeeded" + execution.result_summary = "frozen result" + return execution + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr(tool_step_service, "mark_tool_execution_succeeded", mark) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None), + tool_provider=forbidden_provider, + tool_executor=execute, + ) + + result = await service.execute_pending(state, context, (call,)) + + assert result.error is None + assert result.messages[0]["execution_status"] == "succeeded" + + +@pytest.mark.asyncio +async def test_new_checkpoint_context_mismatch_fails_before_provider_or_receipt() -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _call("call-corrupt", "read_file") + state = _state(tenant_id, agent, (call,)) + _with_step_tool_context(state, call, context_tool_name="write_file") + + async def forbidden(*args, **kwargs): + raise AssertionError(f"corrupt context crossed execution boundary: {args}, {kwargs}") + + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None), + tool_provider=forbidden, + tool_executor=forbidden, + ) + + result = await service.execute_pending(state, _context(state), (call,)) + + assert result.error is not None + assert result.error["code"] == "tool_context_corrupt" + + +@pytest.mark.asyncio +async def test_new_checkpoint_keeps_current_durable_cancel_gate_without_provider() -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _call("call-cancelled", "read_file") + state = _state(tenant_id, agent, (call,)) + _with_step_tool_context(state, call) + signal = CancelSignal(command_id="cancel-1", reason="user stopped") + + async def forbidden(*args, **kwargs): + raise AssertionError(f"cancelled Call crossed execution boundary: {args}, {kwargs}") + + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(signal), + tool_provider=forbidden, + tool_executor=forbidden, + ) + + result = await service.execute_pending(state, _context(state), (call,)) + + assert result.cancel_signal is signal + assert result.messages == () + + +@pytest.mark.asyncio +async def test_legacy_pending_batch_resolves_workset_once_then_reuses_context( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + first_call = _call("legacy-call-1", "read_file") + second_call = _call("legacy-call-2", "write_file") + state = _state(tenant_id, agent, (first_call, second_call)) + context = _context(state) + executions = { + call_id: _execution( + tenant_id, + uuid.UUID(context.run_id), + call_id, + tool_name, + ) + for call_id, tool_name in ( + ("legacy-call-1", "read_file"), + ("legacy-call-2", "write_file"), + ) + } + provider_calls = 0 + + async def tools_once(agent_id: uuid.UUID) -> list[dict]: + nonlocal provider_calls + del agent_id + provider_calls += 1 + if provider_calls > 1: + raise AssertionError("legacy pending batch rebuilt its Workset") + return await _tools(agent.id) + + async def reserve(db, **kwargs): + del db + return _reservation(executions[kwargs["tool_call_id"]]) + + async def execute(name, *args, **kwargs): + del args, kwargs + return ToolExecutionOutcome( + status="succeeded", + result_summary=f"{name} done", + result_ref=None, + ) + + async def mark(db, **kwargs): + del db + execution = executions[kwargs["execution_id"]] if isinstance(kwargs["execution_id"], str) else next( + item for item in executions.values() if item.id == kwargs["execution_id"] + ) + execution.status = "succeeded" + execution.result_summary = kwargs["result_summary"] + return execution + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr(tool_step_service, "mark_tool_execution_succeeded", mark) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None, None), + tool_provider=tools_once, + tool_executor=execute, + ) + + first_result = await service.execute_pending(state, context, (first_call,)) + assert first_result.step_tool_context is not None + assert first_result.step_tool_context["legacy_resolved"] is True + state["lifecycle"]["step_tool_context"] = first_result.step_tool_context + state["lifecycle"]["pending_tool_calls"] = [second_call] + + second_result = await service.execute_pending(state, context, (second_call,)) + + assert second_result.error is None + assert provider_calls == 1 + + +@pytest.mark.asyncio +async def test_legacy_batch_records_compatibility_usage_and_explicit_delete_gate( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _call("legacy-observed", "read_file") + state = _state(tenant_id, agent, (call,)) + execution = _execution( + tenant_id, + uuid.UUID(state["registry"].run_id), + "legacy-observed", + "read_file", + ) + warnings: list[tuple[object, ...]] = [] + + async def reserve(db, **_kwargs): + del db + return _reservation(execution) + + async def execute(*_args, **_kwargs): + return ToolExecutionOutcome( + status="succeeded", + result_summary="done", + result_ref=None, + ) + + async def mark(db, **kwargs): + del db + execution.status = "succeeded" + execution.result_summary = kwargs["result_summary"] + return execution + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr(tool_step_service, "mark_tool_execution_succeeded", mark) + monkeypatch.setattr( + tool_step_service.logger, + "warning", + lambda *args: warnings.append(args), + ) + + result = await _service( + agent, + _CancelSource(None), + execute, + ).execute_pending(state, _context(state), (call,)) + + assert result.error is None + assert result.step_tool_context is not None + assert result.step_tool_context["legacy_resolved"] is True + assert len(warnings) == 1 + assert "legacy_tool_context_resolved" in str(warnings[0][0]) + assert tool_step_service.legacy_tool_context_deletion_ready( + observed_legacy_batches=0, + full_supported_release_elapsed=True, + rollback_window_closed=True, + ) + assert not tool_step_service.legacy_tool_context_deletion_ready( + observed_legacy_batches=1, + full_supported_release_elapsed=True, + rollback_window_closed=True, ) @@ -2611,7 +2977,7 @@ async def test_retryable_read_exhaustion_returns_one_non_retryable_result( "call-read-exhausted", "read_file", ) - execution.attempt_count = 3 + execution.attempt_count = 10 async def reserve(db, **kwargs): del db @@ -2651,7 +3017,7 @@ async def mark_failed(db, **kwargs): assert "Do not repeat the identical tool call unchanged" in result.messages[0][ "content" ] - assert execution.result_metadata["runtime_attempt_count"] == 3 + assert execution.result_metadata["runtime_attempt_count"] == 10 assert execution.result_metadata["runtime_retry_exhausted"] is True assert execution.result_metadata["last_error_code"] == "temporary_read_failure" @@ -2804,6 +3170,147 @@ async def forbidden(*args, **kwargs): assert result.pending_tool_calls == (call,) +def _accepted_for_control_test( + *, + tool_name: str, + effect: str, + retry_policy: str, + deadline_policy: str = "runtime_default", +) -> AcceptedToolCall: + return AcceptedToolCall( + call_instance_id="controlled-call", + provider_call_id="provider-call", + entry=ToolWorksetEntry( + tool_name=tool_name, + contract_version=f"runtime:{tool_name}:v1", + parameters_schema={"type": "object", "properties": {}}, + binding=ToolExecutionBinding(kind="builtin", handler_key=tool_name), + effect=effect, # type: ignore[arg-type] + retry_policy=retry_policy, # type: ignore[arg-type] + deadline_policy=deadline_policy, + ), + ) + + +@pytest.mark.asyncio +async def test_inflight_cancel_stops_waiting_and_marks_possible_write_unknown( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + state = _state(tenant_id, agent, ()) + context = _context(state) + signal = CancelSignal(command_id="cancel-live", reason="user_abort") + operation_cancelled = asyncio.Event() + + async def execute(*_args, **_kwargs): + try: + await asyncio.Event().wait() + finally: + operation_cancelled.set() + + service = _service(agent, _CancelSource(signal), execute) + + async def fence(**_kwargs): + return None + + monkeypatch.setattr(service, "_assert_execution_fence", fence) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "controlled-call", + "write_file", + ) + + outcome, observed_signal = await service._execute_application_with_controls( + state=state, + context=context, + tenant_id=tenant_id, + agent=agent, + accepted=_accepted_for_control_test( + tool_name="write_file", + effect="external_write", + retry_policy="never", + ), + arguments={}, + reservation=_reservation(execution), + lease_owner=execution.lease_owner, + ) + + assert observed_signal == signal + assert operation_cancelled.is_set() + assert outcome.status == "unknown" + assert outcome.error_code == "tool_cancelled_outcome_unknown" + assert outcome.retryable is False + assert outcome.model_action == "reconcile" + assert outcome.side_effect_state == "unknown" + assert outcome.metadata["cancel_propagation"] == "stop_waiting_only" + + +@pytest.mark.asyncio +async def test_long_application_handler_renews_lease_and_fences_before_return( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + state = _state(tenant_id, agent, ()) + context = _context(state) + events: list[str] = [] + + async def execute(*_args, **_kwargs): + await asyncio.sleep(0.12) + events.append("handler_done") + return ToolExecutionOutcome( + status="succeeded", + result_summary="done", + result_ref=None, + ) + + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(), + tool_provider=_tools, + tool_executor=execute, + lease_ttl_seconds=0.15, # type: ignore[arg-type] + ) + + async def renew(**_kwargs): + events.append("renew") + + async def fence(**_kwargs): + events.append("fence") + + monkeypatch.setattr(service, "_renew_execution_lease", renew) + monkeypatch.setattr(service, "_assert_execution_fence", fence) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "controlled-call", + "read_file", + ) + + outcome, signal = await service._execute_application_with_controls( + state=state, + context=context, + tenant_id=tenant_id, + agent=agent, + accepted=_accepted_for_control_test( + tool_name="read_file", + effect="read", + retry_policy="safe", + ), + arguments={}, + reservation=_reservation(execution), + lease_owner=execution.lease_owner, + ) + + assert signal is None + assert outcome.status == "succeeded" + assert events[0] == "fence" + assert "renew" in events + assert events[-1] == "fence" + + @pytest.mark.asyncio async def test_active_safe_read_receipt_defers_command_without_provider_replay( monkeypatch, diff --git a/backend/tests/test_agent_runtime_tool_validation.py b/backend/tests/test_agent_runtime_tool_validation.py new file mode 100644 index 000000000..d8fe5f058 --- /dev/null +++ b/backend/tests/test_agent_runtime_tool_validation.py @@ -0,0 +1,92 @@ +"""Accepted Tool schema validation contract tests.""" + +from app.services.agent_runtime.tool_validation import validate_tool_arguments + + +def _schema() -> dict: + return { + "type": "object", + "properties": { + "path": {"type": "string"}, + "count": {"type": "integer"}, + "mode": {"type": "string", "enum": ["fast", "safe"]}, + "options": { + "type": "object", + "properties": {"dry_run": {"type": "boolean"}}, + "additionalProperties": False, + }, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["path", "mode"], + "additionalProperties": False, + } + + +def test_valid_arguments_match_the_accepted_schema() -> None: + assert validate_tool_arguments( + { + "path": "notes.md", + "count": 2, + "mode": "safe", + "options": {"dry_run": True}, + "tags": ["one", "two"], + }, + _schema(), + ) == () + + +def test_missing_required_wrong_type_enum_and_unknown_fields_are_bounded() -> None: + issues = validate_tool_arguments( + { + "count": True, + "mode": "dangerous", + "options": {"unexpected": "secret-value-must-not-echo"}, + "extra": "private-value-must-not-echo", + }, + _schema(), + ) + + assert [(issue.code, issue.path) for issue in issues] == [ + ("required", "$.path"), + ("type", "$.count"), + ("enum", "$.mode"), + ("additional_property", "$.options.unexpected"), + ("additional_property", "$.extra"), + ] + assert all("secret-value" not in issue.summary for issue in issues) + assert all("private-value" not in issue.summary for issue in issues) + + +def test_array_item_and_nested_object_types_are_validated() -> None: + issues = validate_tool_arguments( + { + "path": "notes.md", + "mode": "fast", + "options": {"dry_run": "yes"}, + "tags": ["ok", 2], + }, + _schema(), + ) + + assert [(issue.code, issue.path) for issue in issues] == [ + ("type", "$.options.dry_run"), + ("type", "$.tags[1]"), + ] + + +def test_any_of_required_alternatives_accept_one_complete_branch() -> None: + schema = { + "type": "object", + "properties": { + "path": {"type": "string"}, + "document_id": {"type": "string"}, + }, + "anyOf": [ + {"required": ["path"]}, + {"required": ["document_id"]}, + ], + } + + assert validate_tool_arguments({"document_id": "doc-1"}, schema) == () + issues = validate_tool_arguments({}, schema) + assert [(issue.code, issue.path) for issue in issues] == [("any_of", "$")] diff --git a/backend/tests/test_agent_tools_deadlines.py b/backend/tests/test_agent_tools_deadlines.py new file mode 100644 index 000000000..b05516bbc --- /dev/null +++ b/backend/tests/test_agent_tools_deadlines.py @@ -0,0 +1,174 @@ +"""Operation-specific Tool deadline and cancellation contracts.""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.services import agent_tools, agentbay_client +from app.services.agent_runtime.tool_contracts import ( + deadline_policy_for_tool, + resolve_tool_deadline_seconds, + tool_cancel_capability, +) + + +def test_deadline_precedence_is_explicit_then_default_capped_by_policy() -> None: + assert resolve_tool_deadline_seconds("network_read") == 30 + assert resolve_tool_deadline_seconds("network_read", 12) == 12 + assert resolve_tool_deadline_seconds("network_read", 120) == 60 + assert deadline_policy_for_tool("read_emails").name == "network_read" + assert deadline_policy_for_tool("execute_code").name == "local_code" + assert tool_cancel_capability("local_code") == "cooperative" + assert tool_cancel_capability("agentbay_code") == "stop_waiting_only" + + +@pytest.mark.asyncio +async def test_public_dns_resolution_uses_a_bounded_deadline(monkeypatch) -> None: + observed: list[float | None] = [] + + async def expire(awaitable, *, timeout=None): + observed.append(timeout) + awaitable.cancel() + raise TimeoutError + + monkeypatch.setattr(agent_tools.asyncio, "wait_for", expire) + + normalized, error = await agent_tools._validate_public_http_url( + "https://deadline.example.test/path" + ) + + assert normalized is None + assert "Could not resolve hostname" in (error or "") + assert observed == [agent_tools.PUBLIC_DNS_DEADLINE_SECONDS] + + +@pytest.mark.asyncio +async def test_imap_read_uses_a_bounded_operation_deadline(monkeypatch) -> None: + observed: list[float | None] = [] + + async def email_config(_agent_id): + return {} + + def resolve_config(_stored): + return ( + { + "imap_host": "imap.example.test", + "imap_port": 993, + "email_address": "agent@example.test", + "auth_code": "redacted", + }, + frozenset({"imap"}), + ) + + async def expire(awaitable, *, timeout=None): + observed.append(timeout) + awaitable.close() + raise TimeoutError + + monkeypatch.setattr(agent_tools, "_get_email_config", email_config) + monkeypatch.setattr( + agent_tools, + "_resolve_local_email_configuration", + resolve_config, + ) + monkeypatch.setattr(agent_tools.asyncio, "wait_for", expire) + + outcome = await agent_tools._read_emails_outcome(uuid.uuid4(), {}) + + assert outcome.status == "failed" + assert outcome.error_code == "email_imap_deadline_exceeded" + assert outcome.retryable is True + assert observed == [agent_tools.EMAIL_IMAP_DEADLINE_SECONDS] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "args", "timeout"), + [ + ("code_execute", ("python", "print('ok')"), 7), + ("code_read_file", ("/tmp/report.txt",), 11), + ], +) +async def test_agentbay_code_operations_enforce_sdk_wait_deadline( + monkeypatch, + method: str, + args: tuple[str, ...], + timeout: int, +) -> None: + client = object.__new__(agentbay_client.AgentBayClient) + client._image_type = "code" + client._session = SimpleNamespace( + code=SimpleNamespace(run_code=lambda *_args: None), + file_system=SimpleNamespace(read_file=lambda *_args: None), + ) + observed: list[float | None] = [] + + async def expire(awaitable, *, timeout=None): + observed.append(timeout) + awaitable.close() + raise TimeoutError + + monkeypatch.setattr(agentbay_client.asyncio, "wait_for", expire) + + with pytest.raises(TimeoutError): + await getattr(client, method)(*args, timeout=timeout) + + assert observed == [timeout] + + +@pytest.mark.asyncio +async def test_typed_agentbay_read_forwards_resolved_deadline(monkeypatch) -> None: + observed: list[int] = [] + + class Client: + async def code_read_file(self, remote_path: str, timeout: int): + assert remote_path == "/tmp/report.txt" + observed.append(timeout) + return SimpleNamespace(success=True, content="body") + + async def get_client(*_args, **_kwargs): + return Client() + + monkeypatch.setattr( + agentbay_client, + "get_agentbay_client_for_agent", + get_client, + ) + + outcome = await agent_tools._agentbay_read_outcome( + "agentbay_code_read_file", + uuid.uuid4(), + {"remote_path": "/tmp/report.txt", "timeout": 120}, + session_id="session-1", + ) + + assert outcome.status == "succeeded" + assert observed == [60] + + +@pytest.mark.asyncio +async def test_local_code_cancellation_terminates_child_and_cleans_script( + tmp_path: Path, +) -> None: + task = asyncio.create_task( + agent_tools._execute_code_legacy_outcome( + tmp_path, + { + "language": "python", + "code": "import time\ntime.sleep(60)", + "timeout": 60, + }, + ) + ) + await asyncio.sleep(0.1) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert not (tmp_path / "_exec_tmp.py").exists() diff --git a/backend/tests/test_agent_tools_legacy_contract_compatibility.py b/backend/tests/test_agent_tools_legacy_contract_compatibility.py index 157d41008..1cecf14a5 100644 --- a/backend/tests/test_agent_tools_legacy_contract_compatibility.py +++ b/backend/tests/test_agent_tools_legacy_contract_compatibility.py @@ -145,3 +145,95 @@ async def typed_outcome(agent_id, workspace, arguments, provider): assert calls == 1 assert result == "✅ Image generated with a durable workspace receipt." + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_name", "arguments", "adapter_name"), + ( + ("read_file", {"path": "workspace/report.md"}, "_read_file_outcome"), + ( + "agentbay_code_read_file", + {"remote_path": "/tmp/report.md"}, + "_agentbay_read_outcome", + ), + ), +) +async def test_registered_builtin_and_agentbay_read_keep_typed_adapters( + monkeypatch: pytest.MonkeyPatch, + tool_name: str, + arguments: dict, + adapter_name: str, +) -> None: + calls: list[tuple[tuple, dict]] = [] + + async def adapter(*args, **kwargs): + calls.append((args, kwargs)) + return ToolExecutionOutcome( + status="succeeded", + result_summary=f"{tool_name} typed receipt", + result_ref=None, + ) + + monkeypatch.setattr(agent_tools, adapter_name, adapter) + if tool_name == "read_file": + async def tenant(_agent_id): + return str(uuid.uuid4()) + + monkeypatch.setattr(agent_tools, "_get_agent_tenant_id", tenant) + + outcome = await agent_tools.execute_builtin_tool_outcome( + tool_name, + arguments, + uuid.uuid4(), + uuid.uuid4(), + session_id="session-registered", + ) + + assert isinstance(outcome, ToolExecutionOutcome) + assert outcome.status == "succeeded" + assert calls + + +@pytest.mark.asyncio +async def test_registered_dynamic_mcp_keeps_exact_typed_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_id = uuid.uuid4() + target = { + "full_name": "tenant_search", + "raw_name": "search", + "server_url": "https://mcp.example.test", + } + calls: list[tuple[dict, dict, uuid.UUID]] = [] + + async def resolve(tool_name, resolved_agent_id): + assert tool_name == "tenant_search" + assert resolved_agent_id == agent_id + return target + + async def execute(resolved_target, arguments, *, agent_id): + calls.append((resolved_target, arguments, agent_id)) + return ToolExecutionOutcome( + status="succeeded", + result_summary="MCP typed receipt", + result_ref=None, + ) + + monkeypatch.setattr(agent_tools, "_resolve_mcp_execution_target", resolve) + monkeypatch.setattr( + agent_tools, + "_execute_resolved_mcp_target_outcome", + execute, + ) + + outcome = await agent_tools.execute_builtin_tool_outcome( + "tenant_search", + {"query": "contract"}, + agent_id, + uuid.uuid4(), + ) + + assert isinstance(outcome, ToolExecutionOutcome) + assert outcome.status == "succeeded" + assert calls == [(target, {"query": "contract"}, agent_id)] diff --git a/backend/tests/test_agent_tools_remaining_typed_outcomes.py b/backend/tests/test_agent_tools_remaining_typed_outcomes.py index 7958dc859..7824d2e36 100644 --- a/backend/tests/test_agent_tools_remaining_typed_outcomes.py +++ b/backend/tests/test_agent_tools_remaining_typed_outcomes.py @@ -102,6 +102,52 @@ async def configured(_agent_id, name): ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("trigger_type", "config"), + [ + ("once", {"at": "tomorrow"}), + ("interval", {"minutes": "30"}), + ("interval", {"minutes": True}), + ("interval", {"minutes": 0}), + ("poll", {"url": "/relative"}), + ("poll", {"url": "https://example.test", "method": "POST"}), + ( + "poll", + {"url": "https://example.test", "headers": {"X-Test": 1}}, + ), + ( + "poll", + {"url": "https://example.test", "fire_on": "match"}, + ), + ("cron", {"expr": "0 9 * * *", "timezone": "Mars/Olympus"}), + ("webhook", {"url": "https://example.test"}), + ], +) +async def test_set_trigger_rejects_invalid_config_before_database_access( + monkeypatch, + trigger_type: str, + config: dict, +) -> None: + def forbidden_session(): + raise AssertionError("invalid trigger config reached the database") + + monkeypatch.setattr(agent_tools, "async_session", forbidden_session) + + outcome = await agent_tools._handle_set_trigger_outcome( + uuid.uuid4(), + { + "name": "invalid-trigger", + "type": trigger_type, + "config": config, + "reason": "validate me", + }, + ) + + assert outcome.status == "failed" + assert outcome.error_code == "invalid_tool_arguments" + + @pytest.mark.asyncio @pytest.mark.parametrize("tool_name", sorted(REMAINING_DEFAULT_TYPED_TOOLS)) async def test_remaining_default_tools_have_native_typed_validation_failures( diff --git a/backend/tests/test_agent_tools_typed_content_outcomes.py b/backend/tests/test_agent_tools_typed_content_outcomes.py index faba4f2a2..b98a7a7ad 100644 --- a/backend/tests/test_agent_tools_typed_content_outcomes.py +++ b/backend/tests/test_agent_tools_typed_content_outcomes.py @@ -140,6 +140,27 @@ def test_document_reader_returns_structured_parse_fact(tmp_path: Path) -> None: assert failure.error_code == "document_format_unsupported" +def test_document_reader_reports_content_truncation_without_fake_continuation( + tmp_path: Path, +) -> None: + (tmp_path / "long.txt").write_text("x" * 100, encoding="utf-8") + + result = agent_tools._read_document_sync( + tmp_path, + "long.txt", + max_chars=20, + ) + + assert result.ok is True + assert result.truncated is True + assert result.processed_scope == { + "characters_total": 100, + "characters_returned": 20, + } + assert "first 20 of 100 extracted characters" in result.content + assert "No continuation parameter is available" in result.content + + def test_document_reader_extracts_pptx_slides_without_slicing( tmp_path: Path, ) -> None: @@ -202,6 +223,50 @@ async def read_result(*args, **kwargs): assert outcome.evidence_refs == (f"workspace://{agent_id}/workspace/report.pdf",) +@pytest.mark.asyncio +async def test_document_outcome_preserves_structured_truncation_fact( + monkeypatch, +) -> None: + agent_id = uuid.uuid4() + + class TempWorkspace: + root = Path("/tmp/typed-document-truncation-test") + + def cleanup(self): + return None + + async def prepare(*args, **kwargs): + return TempWorkspace() + + async def read_result(*args, **kwargs): + return agent_tools.DocumentReadResult( + True, + "partial document", + truncated=True, + processed_scope={"pages_processed": 50, "pages_total": 72}, + truncation_reasons=("processed the first 50 of 72 pages",), + ) + + monkeypatch.setattr(agent_tools, "_prepare_temp_workspace", prepare) + monkeypatch.setattr(agent_tools, "_read_document_result", read_result) + + outcome = await agent_tools._read_document_outcome( + agent_id, + {"path": "workspace/report.pdf"}, + tenant_id=None, + ) + + assert outcome.status == "succeeded" + assert outcome.metadata["content_truncated"] is True + assert outcome.metadata["document_processed_scope"] == { + "pages_processed": 50, + "pages_total": 72, + } + assert outcome.metadata["document_truncation_reasons"] == [ + "processed the first 50 of 72 pages" + ] + + @pytest.mark.asyncio async def test_read_webpage_uses_http_fact_and_marks_read_timeout_retryable( monkeypatch, diff --git a/backend/tests/test_builtin_tool_contracts.py b/backend/tests/test_builtin_tool_contracts.py index a11fd1210..aa39ed02d 100644 --- a/backend/tests/test_builtin_tool_contracts.py +++ b/backend/tests/test_builtin_tool_contracts.py @@ -23,6 +23,14 @@ validate_builtin_tool_definitions, ) from app.services.agent_runtime.tool_execution import ToolExecutionOutcome +from app.services.agent_runtime.tool_contracts import ToolContractError +from app.services.agent_runtime.tool_registry import ( + STATIC_REGISTERED_TOOL_NAMES, + RegisteredTool, + registered_dynamic_mcp, + registered_tool, + resolve_registered_tool, +) def _model_by_name() -> dict[str, dict]: @@ -86,6 +94,42 @@ def test_builtin_model_definition_ignores_stale_database_contract() -> None: assert canonical != stale +def test_active_workset_descriptions_do_not_reference_invisible_tools() -> None: + projected = agent_tools._project_active_tool_descriptions( + [ + builtin_model_definition("write_file"), + builtin_model_definition("read_file"), + builtin_model_definition("update_objective"), + ] + ) + functions = { + tool["function"]["name"]: tool["function"] for tool in projected + } + + assert "list_files" not in functions["write_file"]["description"] + assert "read_document" not in functions["read_file"]["description"] + assert "get_my_okr" not in functions["update_objective"]["description"] + assert "create_objective" not in functions["update_objective"]["description"] + objective_id = functions["update_objective"]["parameters"]["properties"][ + "objective_id" + ]["description"] + assert "get_my_okr" not in objective_id + assert "get_okr" not in objective_id + + +def test_active_workset_projection_preserves_available_tool_references() -> None: + canonical_write = builtin_model_definition("write_file") + projected = agent_tools._project_active_tool_descriptions( + [ + canonical_write, + builtin_model_definition("list_files"), + ] + ) + + assert projected[0] is canonical_write + assert "list_files" in projected[0]["function"]["description"] + + def test_known_schema_contracts_match_handler_validation() -> None: write_file = builtin_model_definition("write_file")["function"]["parameters"] send_channel = builtin_model_definition("send_channel_message")["function"]["parameters"] @@ -238,6 +282,66 @@ def test_runtime_resolver_hides_every_application_tool_without_typed_boundary() assert agent_tools.RUNTIME_TYPED_APPLICATION_TOOL_NAMES <= BUILTIN_TOOL_NAMES +def test_registered_tool_requires_a_complete_execution_contract() -> None: + definition = builtin_model_definition("read_file") + assert definition is not None + + with pytest.raises(ToolContractError, match="authorization"): + RegisteredTool( + model_definition=definition, + binding_kind="builtin", + handler_key="read_file", + effect="read", + retry_policy="safe", + authorization_policy="", + recovery_policy="runtime_default", + deadline_policy="runtime_default", + cancel_capability="stop_waiting_only", + contract_version="registered:read_file:test", + ) + + +def test_registry_exposes_only_complete_static_entries_and_hides_schema_drift() -> None: + assert STATIC_REGISTERED_TOOL_NAMES == { + "read_file", + "agentbay_code_read_file", + } + assert registered_tool("read_file") is not None + assert registered_tool("not_migrated_yet") is None + + stale = deepcopy(builtin_model_definition("read_file")) + stale["function"]["parameters"] = {"type": "object", "properties": {}} + assert resolve_registered_tool(stale) is None + + +def test_dynamic_mcp_registry_uses_exact_name_and_conservative_policies() -> None: + definition = { + "type": "function", + "function": { + "name": "tenant_search", + "description": "Search one tenant provider.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + }, + } + + assert resolve_registered_tool(definition) is None + registered = resolve_registered_tool( + definition, + dynamic_mcp_names={"tenant_search"}, + ) + + assert registered is not None + assert registered == registered_dynamic_mcp(definition) + entry = registered.to_workset_entry() + assert entry.binding.kind == "mcp" + assert entry.effect == "external_write" + assert entry.retry_policy == "never" + + def test_local_content_batch_has_native_runtime_outcomes_before_becoming_visible() -> None: expected = { "execute_code", diff --git a/backend/tests/test_enterprise_info_tenant_migration.py b/backend/tests/test_enterprise_info_tenant_migration.py new file mode 100644 index 000000000..bf42fef0f --- /dev/null +++ b/backend/tests/test_enterprise_info_tenant_migration.py @@ -0,0 +1,128 @@ +"""Schema-state contracts for the enterprise_info tenant migration.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] + / "alembic" + / "versions" + / "v1_0_0_f061_enterprise_info_tenant_id.py" +) + + +def _load_migration(): + spec = importlib.util.spec_from_file_location( + "enterprise_info_tenant_migration", + MIGRATION_PATH, + ) + assert spec is not None and spec.loader is not None + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + return migration + + +def test_upgrade_is_noop_when_fresh_schema_already_has_target_shape( + monkeypatch, +) -> None: + migration = _load_migration() + monkeypatch.setattr( + migration, + "_schema_names", + lambda **_kwargs: ( + {"tenant_id", "info_type"}, + {"ix_enterprise_info_tenant_id"}, + {"uq_enterprise_info_tenant_type"}, + ), + ) + for operation in ( + "add_column", + "create_index", + "drop_constraint", + "create_unique_constraint", + ): + monkeypatch.setattr( + migration.op, + operation, + lambda *args, _operation=operation, **kwargs: (_ for _ in ()).throw( + AssertionError( + f"unexpected {_operation}: {args}, {kwargs}" + ) + ), + ) + + migration.upgrade() + + +def test_upgrade_moves_legacy_schema_to_tenant_scoped_shape(monkeypatch) -> None: + migration = _load_migration() + monkeypatch.setattr( + migration, + "_schema_names", + lambda **_kwargs: ( + {"info_type"}, + set(), + {"enterprise_info_info_type_key"}, + ), + ) + calls: list[tuple[str, tuple, dict]] = [] + for operation in ( + "add_column", + "create_index", + "drop_constraint", + "create_unique_constraint", + ): + monkeypatch.setattr( + migration.op, + operation, + lambda *args, _operation=operation, **kwargs: calls.append( + (_operation, args, kwargs) + ), + ) + + migration.upgrade() + + assert [operation for operation, _, _ in calls] == [ + "add_column", + "create_index", + "drop_constraint", + "create_unique_constraint", + ] + + +def test_downgrade_reverses_only_present_target_objects(monkeypatch) -> None: + migration = _load_migration() + monkeypatch.setattr( + migration, + "_schema_names", + lambda **_kwargs: ( + {"tenant_id", "info_type"}, + {"ix_enterprise_info_tenant_id"}, + {"uq_enterprise_info_tenant_type"}, + ), + ) + calls: list[tuple[str, tuple, dict]] = [] + for operation in ( + "drop_constraint", + "create_unique_constraint", + "drop_index", + "drop_column", + ): + monkeypatch.setattr( + migration.op, + operation, + lambda *args, _operation=operation, **kwargs: calls.append( + (_operation, args, kwargs) + ), + ) + + migration.downgrade() + + assert [operation for operation, _, _ in calls] == [ + "drop_constraint", + "create_unique_constraint", + "drop_index", + "drop_column", + ] diff --git a/backend/tests/test_finish_protocol.py b/backend/tests/test_finish_protocol.py index a63f0497a..e61e66227 100644 --- a/backend/tests/test_finish_protocol.py +++ b/backend/tests/test_finish_protocol.py @@ -811,7 +811,7 @@ async def test_repeated_invalid_tool_json_is_bounded_by_protocol_code(monkeypatc } ], ) - fake_client = FakeStreamClient([invalid, invalid]) + fake_client = FakeStreamClient([invalid] * 11) monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((50, None))) monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray")) monkeypatch.setattr( @@ -841,12 +841,12 @@ async def test_repeated_invalid_tool_json_is_bounded_by_protocol_code(monkeypatc ) assert result.startswith("[Error] invalid_tool_call_protocol_violation:") - assert len(fake_client.messages_seen) == 2 + assert len(fake_client.messages_seen) == 11 assert fake_client.closed is True @pytest.mark.asyncio -async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): +async def test_invalid_write_file_json_gets_ten_bounded_repairs(monkeypatch): from app.services.llm import caller from app.services.llm.client import LLMResponse @@ -863,7 +863,7 @@ async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): } ], ) - fake_client = FakeStreamClient([invalid, invalid, invalid, invalid]) + fake_client = FakeStreamClient([invalid] * 11) monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((50, None))) monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray")) monkeypatch.setattr( @@ -897,7 +897,7 @@ async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): "本次文件生成未完成:write_file 工具参数无效或被截断,连续重试后仍无法执行。" "请回复「重新生成」,我会基于当前对话重新尝试。" ) - assert len(fake_client.messages_seen) == 4 + assert len(fake_client.messages_seen) == 11 assert fake_client.closed is True diff --git a/backend/tests/test_llm_single_step.py b/backend/tests/test_llm_single_step.py index 0b42ac78f..91a1d659b 100644 --- a/backend/tests/test_llm_single_step.py +++ b/backend/tests/test_llm_single_step.py @@ -15,6 +15,7 @@ extract_embedded_reasoning, ) from app.services.llm import single_step +from app.services.llm.utils import get_tool_params _TINY_PNG_DATA_URL = ( @@ -40,6 +41,42 @@ async def close(self) -> None: self.closed = True +def test_provider_parallel_capability_is_independent_from_tool_choice() -> None: + tools = [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file.", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + messages = [LLMMessage(role="user", content="Read it")] + + serial_payload = OpenAICompatibleClient( + api_key="test", + model="serial-provider", + supports_tool_choice=True, + supports_parallel_tool_calls=False, + )._build_payload(messages, tools, 0.2, 256) + parallel_payload = OpenAICompatibleClient( + api_key="test", + model="parallel-provider", + supports_tool_choice=True, + supports_parallel_tool_calls=True, + )._build_payload(messages, tools, 0.2, 256) + + assert serial_payload["tool_choice"] == "auto" + assert "parallel_tool_calls" not in serial_payload + assert parallel_payload["parallel_tool_calls"] is True + assert get_tool_params("deepseek") == {"tool_choice": "auto"} + assert get_tool_params("openai") == { + "tool_choice": "auto", + "parallel_tool_calls": True, + } + + def _model(): return SimpleNamespace( provider="openai", diff --git a/backend/tests/test_tool_execution.py b/backend/tests/test_tool_execution.py index 107810153..37157be5f 100644 --- a/backend/tests/test_tool_execution.py +++ b/backend/tests/test_tool_execution.py @@ -1,10 +1,10 @@ """Focused tests for the Runtime Tool Execution Ledger service.""" -from collections import deque -from datetime import UTC, datetime, timedelta import inspect import math import uuid +from collections import deque +from datetime import UTC, datetime, timedelta import pytest from sqlalchemy.dialects import postgresql @@ -13,7 +13,6 @@ from app.models.agent_tool_execution import AgentToolExecution from app.services.agent_runtime import tool_execution - _NOW = datetime(2026, 7, 13, 13, 0, tzinfo=UTC) _ARGUMENTS = {"channel": "ops", "message": "hello"} _SANITIZED_ARGUMENTS = {"channel": "ops", "message": "[redacted]"} @@ -144,19 +143,25 @@ async def _reserve( retry_policy: str = "never", resume_safe_read: bool = False, arguments: dict | None = None, + tool_call_id: str = "call-1", + assistant_message_id: str = "assistant-message-1", + provider_call_id: str | None = None, + contract_version: str | None = None, ): return await tool_execution.reserve_tool_execution( db, tenant_id=tenant_id, run_id=run_id, - tool_call_id="call-1", + tool_call_id=tool_call_id, tool_name="send_message", - assistant_message_id="assistant-message-1", + assistant_message_id=assistant_message_id, arguments=arguments or _ARGUMENTS, sanitized_arguments=_SANITIZED_ARGUMENTS, request_ref="request://1", side_effect_classification=effect, retry_policy=retry_policy, + provider_call_id=provider_call_id, + contract_version=contract_version, lease_owner="worker-1", lease_ttl_seconds=60, resume_safe_read=resume_safe_read, @@ -325,6 +330,38 @@ async def test_new_reservation_atomically_persists_started_and_execution_metadat assert "FOR UPDATE" in locked_sql +@pytest.mark.asyncio +async def test_repeated_provider_id_creates_distinct_call_instance_receipts() -> None: + tenant_id = uuid.uuid4() + run_id = uuid.uuid4() + first_db = _FakeSession(run_id, None) + second_db = _FakeSession(run_id, None) + + first = await _reserve( + first_db, + tenant_id=tenant_id, + run_id=run_id, + tool_call_id="call-instance-1", + assistant_message_id="assistant-message-1", + provider_call_id="provider-local-1", + contract_version="runtime:send_message:v1", + ) + second = await _reserve( + second_db, + tenant_id=tenant_id, + run_id=run_id, + tool_call_id="call-instance-2", + assistant_message_id="assistant-message-2", + provider_call_id="provider-local-1", + contract_version="runtime:send_message:v1", + ) + + assert first.execution.id != second.execution.id + assert first.execution.tool_call_id != second.execution.tool_call_id + assert first.execution.provider_call_id == second.execution.provider_call_id + assert first.execution.contract_version == second.execution.contract_version + + @pytest.mark.asyncio async def test_succeeded_reservation_reuses_receipt_and_never_executes_again(): tenant_id = uuid.uuid4() @@ -388,6 +425,32 @@ async def test_legacy_embedded_policy_metadata_remains_readable_during_backfill( assert reservation.reusable_result.result_summary == "cached" +@pytest.mark.asyncio +async def test_legacy_receipt_with_null_identity_fields_remains_replayable() -> None: + tenant_id = uuid.uuid4() + run_id = uuid.uuid4() + existing = _execution( + tenant_id=tenant_id, + run_id=run_id, + status="succeeded", + result_summary="cached", + ) + existing.provider_call_id = None + existing.contract_version = None + db = _FakeSession(run_id, existing) + + reservation = await _reserve( + db, + tenant_id=tenant_id, + run_id=run_id, + provider_call_id="provider-call-1", + contract_version="runtime:send_message:v1", + ) + + assert reservation.reusable_result is not None + assert reservation.reusable_result.result_summary == "cached" + + @pytest.mark.asyncio @pytest.mark.parametrize( ("status", "requires_confirmation", "error_code"), @@ -818,7 +881,10 @@ async def test_expired_final_safe_read_attempt_closes_without_provider_replay(): assert reservation.prior_failure is not None assert reservation.prior_failure.error_code == "tool_retry_exhausted" assert execution.status == "failed" - assert execution.result_metadata["runtime_attempt_count"] == 3 + assert ( + execution.result_metadata["runtime_attempt_count"] + == tool_execution.SAFE_READ_MAX_ATTEMPTS + ) assert execution.result_metadata["runtime_retry_exhausted"] is True assert db.flush_count == 1 diff --git a/backend/tests/test_unified_runtime_group_migration.py b/backend/tests/test_unified_runtime_group_migration.py index f259e5dcd..b6d45d47f 100644 --- a/backend/tests/test_unified_runtime_group_migration.py +++ b/backend/tests/test_unified_runtime_group_migration.py @@ -3,8 +3,8 @@ from __future__ import annotations import importlib.util -from pathlib import Path import re +from pathlib import Path import pytest import sqlalchemy as sa @@ -31,7 +31,6 @@ from app.models.trigger_execution import TriggerExecution from app.models.workspace import WorkspaceEditLock, WorkspaceFileRevision - VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions" MIGRATION_PATH = VERSIONS_DIR / "202607161200_unify_runtime_group_schema.py" LEGACY_BRANCH_REVISIONS = { @@ -96,6 +95,9 @@ "groups", "group_members", ) +POST_UNIFIED_COLUMNS_BY_TABLE = { + "agent_tool_executions": {"provider_call_id", "contract_version"}, +} def _load_migration(): @@ -109,6 +111,13 @@ def _load_migration(): return module +def _belongs_to_unified_schema(table_name: str, column_name: str) -> bool: + return ( + column_name != "tenant_id" + and column_name not in POST_UNIFIED_COLUMNS_BY_TABLE.get(table_name, set()) + ) + + def _canonical_sql(value: object, *, table_name: str) -> str: sql = str(value).lower() sql = re.sub(rf'(? None: assert { column.name: _column_signature(column) for column in migration_table.columns - if column.name != "tenant_id" + if _belongs_to_unified_schema(table_name, column.name) } == { column.name: _column_signature(column) for column in model_table.columns - if column.name != "tenant_id" + if _belongs_to_unified_schema(table_name, column.name) } assert ( migration_table.primary_key.name, diff --git a/specs/002-tool-runtime-contract/checklists/requirements.md b/specs/002-tool-runtime-contract/checklists/requirements.md new file mode 100644 index 000000000..9c4a731f5 --- /dev/null +++ b/specs/002-tool-runtime-contract/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Tool Runtime 契约与执行链路修复 + +**Purpose**: 在进入规划阶段前验证需求规格的完整性和质量 +**Created**: 2026-08-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- 第一次校验即通过,无 `[NEEDS CLARIFICATION]` 项。 +- `Tool Call`、`Run`、`Receipt`、`checkpoint` 等词是本产品领域对象,不是具体实现方案;具体数据结构、文件和迁移步骤将在 Plan 阶段定义。 +- Spec 已覆盖用户确认的 Tool repair/retry 上限统一为 10、模型可见错误反馈、unknown write 禁止自动重放和旧 checkpoint 兼容边界;计数结构统一重构已明确延期。 diff --git a/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md b/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md new file mode 100644 index 000000000..dc6db0791 --- /dev/null +++ b/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md @@ -0,0 +1,33 @@ +# Contract: Repair Budget and Execution Lifecycle + +## Tool Repair Episode + +- `same_fingerprint_failures` reaches 10: pause immediately after recording the 10th failure; do not invoke model step 11 for that loop. +- `total_failures` reaches 10 for the same Tool episode: pause immediately; do not invoke the next model step. +- Generic Tool protocol repair, `write_file` protocol repair, and safe-read replay retain their current independent counters but each uses a limit of 10; counter unification is deferred. +- Changing fingerprint resets only the consecutive counter. +- Success of the same Tool, new Run, or explicit user correction resets the Tool episode. +- Success of another Tool does not reset it. + +Global `model_turn_limit`, Provider transport retry, Command retry, Receipt safe-read attempt and Verifier episode are independent budgets with independent stop reasons. + +## Verifier Episode + +Verifier attempts belong to a fingerprinted current issue. A passing verification closes the episode. A materially new issue begins at zero; historical repair attempts do not consume its budget. + +## Deadline / Cancel / Lease + +| Control | Meaning | Must not imply | +|---|---|---| +| Operation deadline | Maximum wait for one handler/provider operation | Receipt ownership loss or proof no write occurred | +| Durable cancel | User/platform intent to stop the Run | Automatic rollback of an external write | +| Receipt lease | Which Worker may execute/settle the Receipt | Handler completion deadline | + +Rules: + +1. deadline precedence is explicit call value, then Tool policy default, capped by Tool policy maximum; +2. cancel propagates to supported subprocess/network/SDK operations and otherwise stops waiting with capability telemetry; +3. long Handler renews lease while owning it and fences before side effect/settlement; +4. lease loss prevents stale owner settlement; +5. deadline/cancel/disconnect after a possible write yields unknown/reconcile unless a stable provider/business receipt proves outcome; +6. unknown write cannot be automatically replayed by model, Command retry or Worker restart. diff --git a/specs/002-tool-runtime-contract/contracts/step-tool-context.md b/specs/002-tool-runtime-contract/contracts/step-tool-context.md new file mode 100644 index 000000000..c6dc5e234 --- /dev/null +++ b/specs/002-tool-runtime-contract/contracts/step-tool-context.md @@ -0,0 +1,37 @@ +# Contract: Step Tool Context + +## Producer + +`RuntimeModelStepService` produces version 1 context only after the actual primary/fallback Provider response has been accepted. The context must describe the exact Workset sent to that Provider call. + +## Consumer + +`RuntimeToolStepService` consumes the context before validation, authorization or Receipt reservation. + +## Rules + +1. `assistant_message_id`, pending calls and accepted call entries must match exactly. +2. Tool name, schema, contract version, effect/retry policy and binding come from accepted context. +3. New-format Tool Step must not call ToolProvider or re-evaluate assignment/enabled/channel/readiness. +4. Current tenant, actor, resource, credential, approval and cancel checks remain mandatory. +5. Binding mismatch/corruption fails before Receipt; it is not guessed or rebuilt. +6. Legacy checkpoint may resolve a batch once and must emit compatibility telemetry. + +## Identity + +- `provider_call_id`: optional original wire ID; +- `call_instance_id`: required Clawith ID placed in checkpoint `tool_calls[].id` and DB `tool_call_id`; +- `execution_id`: created/resolved by Receipt reservation. + +Provider output returned to the Provider must use its expected Provider call identity. Internal projections and idempotency use Call Instance/Execution identity. + +## Binding Validity + +Ordinary visibility changes affect the next Model Step only. Hard safety invalidators for an accepted Call are: + +- tenant/actor mismatch; +- resource authorization revoked; +- credential revoked/unavailable; +- exact registered handler/provider target removed without compatible resolver; +- durable Run cancellation; +- corrupted context or contract version unsupported. diff --git a/specs/002-tool-runtime-contract/contracts/tool-result.md b/specs/002-tool-runtime-contract/contracts/tool-result.md new file mode 100644 index 000000000..29e023946 --- /dev/null +++ b/specs/002-tool-runtime-contract/contracts/tool-result.md @@ -0,0 +1,40 @@ +# Contract: Tool Result and Failure Feedback + +## Model-visible Envelope + +```json +{ + "role": "tool", + "tool_call_id": "call_instance_id", + "name": "tool_name", + "execution_status": "succeeded|failed|pending|unknown", + "error_code": "stable_optional_code", + "content": "bounded sanitized summary", + "model_action": "continue|repair_arguments|choose_other_tool|ask_user|wait|reconcile", + "side_effect_state": "none|confirmed|possible|unknown", + "safe_remediation": "optional bounded instruction", + "result_ref": "optional opaque reference" +} +``` + +## Exactly-once Feedback + +- A valid Call Instance with a repairable deterministic failure receives one Tool Result. +- Checkpoint replay reuses the deterministic result message ID and Receipt result. +- Invalid/missing Call identity is protocol corruption and cannot invent a Tool Result pairing. + +## Classification + +| Situation | Runtime state | Count repair? | Automatic replay? | +|---|---|---:|---:| +| Schema/argument failure | failed Tool Result | yes | model decides | +| Deterministic business rejection | failed Tool Result | yes when repairable | model decides | +| Permission/confirmation | waiting | no | no | +| Async operation | pending | no | poll only | +| Durable cancel | cancelled terminal | no | no | +| Possible external write | unknown/reconcile | no | no | +| Provider transport retry | internal | no | bounded safe retry only | + +## Sanitization + +Never include secrets, plaintext credential/config, complete sensitive arguments, stack traces, unbounded provider bodies or raw exception strings. Error codes are stable product vocabulary; summary and remediation have byte limits. diff --git a/specs/002-tool-runtime-contract/data-model.md b/specs/002-tool-runtime-contract/data-model.md new file mode 100644 index 000000000..3a328ddbe --- /dev/null +++ b/specs/002-tool-runtime-contract/data-model.md @@ -0,0 +1,121 @@ +# Data Model: Tool Runtime Contract + +## Identity Model + +| Identity | Scope | Authority | Purpose | +|---|---|---|---| +| `provider_call_id` | Provider assistant response | Provider wire protocol | Assistant/tool response pairing and diagnostics only | +| `call_instance_id` | One accepted Assistant Tool Call inside a Run | Clawith Model Step | Checkpoint, message, Activity, Chat and A2A correlation | +| `execution_id` | One durable Receipt row | PostgreSQL `AgentToolExecution.id` | Lease, attempt, result archive, async poll and reconciliation | +| `business_idempotency_key` | Provider/business operation | Tool adapter/provider | External side-effect deduplication when supported | + +Compatibility mapping: current DB column `tool_call_id` stores `call_instance_id`. It is not renamed in the first migration. + +## Checkpoint Entities + +### StepToolContext + +```json +{ + "version": 1, + "assistant_message_id": "...", + "model_step": 3, + "workset_version": "sha256:...", + "accepted_calls": [ + { + "call_instance_id": "...", + "provider_call_id": "...", + "tool_name": "read_document", + "contract_version": "builtin:read_document:v2", + "schema": {}, + "binding": {}, + "effect": "read", + "retry_policy": "safe" + } + ] +} +``` + +Invariants: + +- one context belongs to exactly one Assistant message; +- `call_instance_id` is unique inside the Run and stable across replay; +- `provider_call_id` may be null for legacy/provider compatibility; +- schema/binding are JSON serializable, bounded and secret-free; +- new checkpoint pending calls must have a matching accepted call entry. + +### ToolWorksetEntry + +Fields: + +- `tool_name`: model-visible name; +- `contract_version`: immutable schema/behavior version; +- `parameters_schema`: accepted model schema; +- `binding`: stable handler/provider target; +- `effect`: `read | write | external_write`; +- `retry_policy`: `safe | conditional | never`; +- `authorization_policy`: stable policy key; +- `deadline_policy`: stable policy key; +- `recovery_policy`: stable policy key. + +### ExecutionBinding + +Allowed forms: + +- builtin: `{kind: "builtin", handler_key: "read_document"}`; +- MCP: `{kind: "mcp", server_id, mcp_tool_name, credential_ref}`; +- group/A2A/AgentBay: stable adapter key plus resource reference. + +Forbidden fields: plaintext credentials, bearer tokens, decrypted config, live client objects, Python callable names that are not registry keys. + +### RepairEpisode + +```json +{ + "tool_name": "read_document", + "episode_id": "...", + "total_failures": 7, + "last_fingerprint": "schema_validation:missing:path", + "same_fingerprint_failures": 3, + "last_call_instance_id": "...", + "updated_at_model_step": 8 +} +``` + +Transitions: + +- count: model-visible and repairable `failed` result; +- reset all for tool: same Tool succeeds or user explicitly corrects the request; +- reset new Run: checkpoint starts empty; +- fingerprint change: reset only `same_fingerprint_failures` to 1; +- exclude: provider retry, safe replay, approval wait, pending, cancel, unknown. + +## PostgreSQL Changes + +### `agent_tool_executions` + +Add nullable columns: + +- `provider_call_id VARCHAR(255)`; +- `contract_version VARCHAR(255)`. + +Keep: + +- primary key `id` as `execution_id`; +- unique `(run_id, tool_call_id)` as Call Instance uniqueness; +- existing attempt, effect, retry, status, result and lease columns. + +No physical foreign keys are added. A non-unique tenant/run/provider index is optional only if observed diagnostics require it; first migration omits it to minimize write cost. + +## State Ownership + +- Workset/context/repair episode: LangGraph checkpoint, because they control execution transition. +- Receipt/result/lease: `AgentToolExecution`, because they are durable side-effect facts. +- Activity/Chat: idempotent projections, never authority for resume or repair counts. + +## Compatibility + +- Legacy Receipt row with null new fields remains readable. +- Legacy checkpoint without `StepToolContext` enters one-batch resolver and marks telemetry. +- New checkpoint with missing/mismatched context is corruption; it cannot silently fall back. +- Deletion of compatibility code requires zero observed uses across retention and rollback windows plus restore fixtures. diff --git a/specs/002-tool-runtime-contract/plan.md b/specs/002-tool-runtime-contract/plan.md new file mode 100644 index 000000000..50d3fef64 --- /dev/null +++ b/specs/002-tool-runtime-contract/plan.md @@ -0,0 +1,130 @@ +# Implementation Plan: Tool Runtime 契约与执行链路修复 + +**Branch**: `002-tool-runtime-contract` | **Date**: 2026-08-10 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/002-tool-runtime-contract/spec.md` + +## Summary + +在现有 Durable Runtime、`AgentToolExecution` Receipt、safe-read replay 和 unknown/reconcile 机制之上,增加一次 Model Step 固化、checkpoint 可恢复的 `StepToolContext`。新 Tool Step 只使用已接受的 Tool Contract/Execution Binding,不再调用 ToolProvider 重建 Workset;同时把 Provider Call ID、Runtime Call Instance 和 Execution Receipt 分离,统一 schema validation、authorization/approval、模型可见失败反馈,并将现有独立 Tool repair/retry 上限统一为 10。操作 deadline、取消传播和 Receipt lease 继续保持三个独立控制面。长期通过可渐进迁移的 RegisteredTool 收敛模型定义与执行能力,不一次性替换现有 Handler。 + +## Technical Context + +**Language/Version**: Python 3.11+ +**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async ORM, PostgreSQL, LangGraph checkpoint, Pydantic, httpx +**Storage**: PostgreSQL `agent_tool_executions` + LangGraph PostgreSQL checkpoint;不新增第二套 Run 生命周期状态机 +**Testing**: pytest, pytest-asyncio, Ruff, Alembic heads/upgrade/downgrade, `scripts/arch-guard.sh` +**Target Platform**: Linux backend workers and API processes +**Project Type**: Multi-tenant web-service backend;本功能无必需前端改动 +**Performance Goals**: 新格式每个 Model Step 最多一次 ToolProvider 查询;Tool Step 为 0 次;checkpoint replay 不增加 ToolProvider 查询或副作用次数 +**Constraints**: 保持旧 checkpoint 可恢复;不新增依赖;所有查询 tenant-scoped;unknown write 禁止自动重放;不弱化 Receipt fence +**Scale/Scope**: Agent Runtime 核心链路、一个兼容型 Alembic 迁移、定向 Runtime/Tool tests;长期 Registry 只建立接口和迁移门槛,不在首轮搬迁全部工具 + +## Constitution Check + +*GATE: Phase 0 前与 Phase 1 后均通过。* + +- **C1 Runtime Boundary Isolation — PASS**:`StepToolContext` 和 repair episode 属于 LangGraph checkpoint 的执行生命周期;`AgentToolExecution` 继续只保存 Receipt/结果事实,API 和产品投影不推进 Runtime 状态。 +- **C2 Strict Multi-Tenant Scope — PASS**:所有 execution/authorization/binding lookup 必须携带 `tenant_id`;binding 不能成为跨 tenant 的可执行引用。 +- **C3 Idempotent Side Effects — PASS**:保留 `AgentToolExecution.id`、lease owner/fence、unknown/reconcile 和 safe-read bounded retry;Call Instance 只增强身份,不绕过 Receipt。 +- **C4 Client/Gateway Wrapper — PASS**:Provider/Tool 调用仍经统一 Runtime/Tool executor;不引入直接外部访问旁路。 +- **C5 Database/Performance — PASS**:迁移不新增物理外键;使用单列/组合索引,不在 Tool loop 内引入 N+1;新 Tool Step 删除一次 Workset 查询。 +- **C6 Modularity/Reusability — PASS**:新增 contract/validation/repair 小模块,避免继续扩张已超过建议尺寸的 `tool_step_service.py` 和 `agent_tools.py`。 + +## Delivery Phases + +### Phase A — Stable Step Tool Context and identity + +1. 定义 `StepToolContext`、`ToolWorksetEntry`、`AcceptedToolCall` 的 checkpoint JSON contract。 +2. Model Step 在 Provider 调用前构建 Workset,在接受 Tool Call 时生成稳定 `call_instance_id` 并保留 `provider_call_id`。 +3. Tool Step 校验 context 与 Assistant Turn 一致,只从保存 binding 执行;新 checkpoint 路径禁止调用 ToolProvider。 +4. 旧 checkpoint 进入单次、可观测的 legacy resolver;同一 pending batch 只解析一次。 +5. `AgentToolExecution` 增加 nullable `provider_call_id` 和 `contract_version`;现有 `tool_call_id` 语义收敛为 Call Instance,`id` 继续是 Execution/Receipt ID。 + +### Phase B — Shared validation, authorization and failure feedback + +1. 在 Receipt reservation 前按已接受 schema 统一校验 object/required/type/enum/additional properties。 +2. 将 actor/tenant/resource/credential/approval 检查收敛为不可绕过的 authorization decision。 +3. 所有具备有效 Call Instance 的可修复失败生成一个 call-linked Tool Result,包含稳定 code、bounded summary、model action、side-effect state 和安全 remediation。 +4. Permission/confirmation、pending、cancel、unknown 和 protocol corruption 继续使用独立控制状态。 + +### Phase C — Repair budgets + +1. checkpoint 保存 per-tool repair episode、连续 fingerprint 计数和总计数。 +2. 第 10 次连续相同失败或第 10 次同 Tool episode 失败后暂停,且不发起下一次模型调用;普通 Tool JSON repair、`write_file` JSON repair 和 safe-read replay 也只把现有独立上限改为 10,不在本轮重构计数结构。 +3. Tool 成功、新 Run、用户明确纠正按 contract 重置;Provider retry、safe internal replay、permission/confirmation、pending、cancel、unknown 不计数。 +4. Verifier repair 改为当前 issue episode 计数,保留全局 `model_turn_limit` 独立语义。 + +### Phase D — Deadlines, cancellation and lease hardening + +1. 为 IMAP、DNS、AgentBay read/code 和本地 code 定义 operation-specific deadline 优先级。 +2. 将 durable cancel 传播到支持的进程/网络/SDK;无法 hard-cancel 的调用停止等待并记录能力限制。 +3. 长任务在 ownership 有效时 renew lease;settlement 前执行 fence;deadline/cancel 后不确定写转 unknown/reconcile。 + +### Phase E — RegisteredTool migration boundary + +建立不影响现有工具的 `RegisteredTool` contract,要求模型 schema、handler binding、effect/retry、authorization、recovery、deadline/cancel capability 完整后才进入 Workset。首轮只迁移代表性 builtin、MCP 和 AgentBay read;其余 legacy adapter 保持隐藏或走兼容层。 + +## Project Structure + +### Documentation + +```text +specs/002-tool-runtime-contract/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── step-tool-context.md +│ ├── tool-result.md +│ └── repair-and-lifecycle.md +└── tasks.md +``` + +### Source Code + +```text +backend/ +├── app/models/agent_tool_execution.py +├── app/services/agent_runtime/ +│ ├── state.py +│ ├── model_step_service.py +│ ├── tool_step_service.py +│ ├── tool_execution.py +│ ├── tool_contracts.py # new: checkpoint-safe contracts/bindings +│ ├── tool_validation.py # new: accepted-schema validation +│ ├── tool_authorization.py # new: shared decision envelope +│ ├── tool_repair_budget.py # new: episode transitions +│ └── cancel_source.py +├── app/services/agent_tools.py +├── alembic/versions/ +└── tests/ + ├── test_agent_runtime_tool_contracts.py + ├── test_agent_runtime_tool_step_service.py + ├── test_agent_runtime_tool_execution.py + ├── test_agent_runtime_tool_repair_budget.py + └── test_agent_tools_deadlines.py +``` + +**Structure Decision**: 只扩展现有 backend Runtime 边界。checkpoint contract、validation、authorization 和 repair budget 拆为小模块;Receipt persistence 继续由现有 model/service 所有,不增加平行 Runtime。 + +## Migration and Compatibility Strategy + +- Alembic 采用 add-only、nullable staged migration;`provider_call_id` 和 `contract_version` 不参与首期唯一键。 +- 现有 `(run_id, tool_call_id)` 唯一键保留;新代码把 `tool_call_id` 当作 Call Instance,旧行保持合法。 +- 旧 checkpoint 无 `step_tool_context` 时,Tool Step 仅为整个 pending batch 调用一次 legacy resolver,并记录 `legacy_tool_context_resolved`;新 checkpoint 缺 context 直接视为 corruption。 +- mixed-version Worker 期间,新字段写入必须向旧 Reader 兼容;删除 legacy path 需要完整保留周期、回滚窗口和使用量为零。 + +## Verification Strategy + +1. Contract unit tests:序列化、版本、Call identity、schema validation、failure redaction、repair transitions。 +2. Runtime integration tests:Model Step → checkpoint → 新 Worker Tool Step;普通 availability 变化不影响已接受 Call;安全状态变化仍阻断。 +3. Receipt tests:replay 复用同一 execution;lease renewal/loss/fence;unknown write no replay;safe read bounded retry。 +4. Compatibility tests:旧 checkpoint 单次 resolver、新 checkpoint 禁止 resolver、mixed-version nullable fields。 +5. Lifecycle tests:统一上限 10 的 off-by-one、reset/exclusion、operation deadline、cancel propagation。 +6. Static gates:scoped Ruff、pytest、Alembic single head + upgrade/downgrade、`scripts/arch-guard.sh`。 + +## Complexity Tracking + +无 Constitution 违规。长期 Registry 和 deadline/cancel 能力表放在后续 phase,避免首个安全修复同时搬迁全部工具。 diff --git a/specs/002-tool-runtime-contract/quickstart.md b/specs/002-tool-runtime-contract/quickstart.md new file mode 100644 index 000000000..40c60830f --- /dev/null +++ b/specs/002-tool-runtime-contract/quickstart.md @@ -0,0 +1,175 @@ +# Quickstart: Tool Runtime Contract Implementation + +## Checkout + +```bash +cd /Users/zhou/Code/clawith-worktrees/tool-runtime-contract-repair +git branch --show-current +git log -1 --oneline +``` + +Expected branch: `002-tool-runtime-contract`; base contains `upstream/main@251aeba8` or a later explicitly rebased upstream main. + +## Baseline Evidence (2026-08-10) + +- Branch: `002-tool-runtime-contract` +- Base: `251aeba8c36513bcab11b1538ecfd758bdf2cbe4` (`upstream/main`) +- Pre-implementation changes: only SpecKit artifacts and its generated `AGENTS.md` technology context; original checkout changes remain isolated. +- Alembic: one head, `f061_enterprise_info_tenant_id`. +- Architecture guard: passed all P0 checks; repository-wide legacy warnings were present before implementation (direct service selects, physical FKs and oversized files). +- Existing directed coverage includes model/tool step, tool outcome, checkpoint side effects, cancel source, async poll, A2A, command worker and `test_tool_execution.py`. + +## Implementation Order + +1. Add contract and identity tests before production edits. +2. Add checkpoint `StepToolContext` and stable Call Instance creation. +3. Remove ToolProvider access from new-format Tool Step; add legacy batch resolver. +4. Add DB columns/migration and projection metadata. +5. Add shared validation/authorization/failure envelope. +6. Add repair episode state and uniform Tool repair/retry limit 10 gates. +7. Harden operation deadlines/cancel/lease tests. +8. Add RegisteredTool boundary and migrate representative tools only. + +## Scoped Verification + +```bash +cd backend +.venv/bin/python -m pytest \ + tests/test_agent_runtime_model_step_service.py \ + tests/test_agent_runtime_tool_step_service.py \ + tests/test_agent_runtime_tool_execution.py \ + tests/test_agent_runtime_tool_contracts.py \ + tests/test_agent_runtime_tool_repair_budget.py +.venv/bin/ruff check \ + app/models/agent_tool_execution.py \ + app/services/agent_runtime \ + tests/test_agent_runtime_tool_contracts.py \ + tests/test_agent_runtime_tool_repair_budget.py +.venv/bin/alembic heads +``` + +Before completion: + +```bash +cd /Users/zhou/Code/clawith-worktrees/tool-runtime-contract-repair +bash scripts/arch-guard.sh +cd backend +.venv/bin/python -m pytest tests/test_agent_runtime_*.py +.venv/bin/alembic downgrade -1 +.venv/bin/alembic upgrade head +``` + +## Proof Scenarios + +- accepted call survives assignment/enabled/readiness change; +- current actor/resource/credential revocation still blocks before side effect; +- checkpoint restart on another Worker uses the same binding and execution row; +- repeated Provider-local ID in another Assistant Turn does not collide; +- schema failure returns exactly one sanitized Tool Result; +- the 10th repair failure pauses before the next model invocation; +- provider retry, safe replay, pending, cancel and unknown do not increment repair budget; +- lease loss blocks stale settlement; uncertain write is never auto-replayed; +- legacy checkpoint resolves once per pending batch, new checkpoint never uses legacy fallback. + +## Completion Evidence (2026-08-11) + +### Runtime and Tool regression + +```bash +backend/.venv/bin/python -m pytest -q \ + backend/tests/test_agent_runtime_*.py \ + backend/tests/test_tool_execution.py \ + backend/tests/test_builtin_tool_contracts.py \ + backend/tests/test_agent_tools_legacy_contract_compatibility.py \ + backend/tests/test_agent_tools_remaining_typed_outcomes.py \ + backend/tests/test_agent_tools_typed_content_outcomes.py \ + backend/tests/test_agent_tools_deadlines.py \ + backend/tests/test_llm_single_step.py +``` + +Result: `834 passed, 3 warnings`. The warnings are existing Pydantic/Lark +deprecations and no test failed. + +### Static and architecture checks + +```bash +backend/.venv/bin/ruff check --select E9,F63,F7,F82 +backend/.venv/bin/ruff check \ + backend/app/services/agent_runtime/tool_contracts.py \ + backend/app/services/agent_runtime/tool_registry.py \ + backend/app/services/agent_runtime/tool_repair_budget.py \ + backend/app/services/agent_runtime/tool_validation.py \ + backend/tests/test_agent_runtime_tool_contracts.py \ + backend/tests/test_agent_runtime_tool_execution_migration.py \ + backend/tests/test_agent_runtime_tool_repair_budget.py \ + backend/tests/test_agent_runtime_tool_validation.py \ + backend/alembic/versions/v1_11_3_f062_tool_execution_identity.py +bash scripts/arch-guard.sh +git diff --check +``` + +Results: + +- fatal Ruff checks passed across every changed Python scope; +- full Ruff passed for the new contract/registry/repair/validation modules, + their focused tests, and migration; +- Architecture Guard passed all P0 checks; +- `git diff --check` passed; +- repository-existing broad Ruff/style debt and Architecture Guard warnings + remain (import/style findings in legacy large files, direct selects, physical + foreign keys, and oversized files). They are not introduced as part of this + contract repair and were not mass-formatted in this focused branch. + +### Migration verification + +```bash +cd backend +.venv/bin/alembic heads +.venv/bin/python -m pytest -q \ + tests/test_agent_runtime_tool_execution_migration.py \ + tests/test_agent_runtime_tool_contracts.py +.venv/bin/alembic upgrade \ + f061_enterprise_info_tenant_id:f062_tool_execution_identity --sql +.venv/bin/alembic downgrade \ + f062_tool_execution_identity:f061_enterprise_info_tenant_id --sql +``` + +Results: + +- exactly one Alembic head: `f062_tool_execution_identity`; +- migration/contract tests: `9 passed`; +- forward SQL adds nullable `provider_call_id` and `contract_version`; +- reverse SQL drops the two fields in reverse order; +- the local PostgreSQL role cannot create an isolated verification database, + while the existing `clawith` database is behind current main. Therefore no + destructive online upgrade/downgrade was run against user data. Both online + schema-introspection behavior and old-row compatibility are covered by the + migration tests; both directions also pass Alembic's offline migration path. + +### Final consistency checks + +- branch: `002-tool-runtime-contract`; +- base: `upstream/main@251aeba8c36513bcab11b1538ecfd758bdf2cbe4`; +- all SpecKit files under `specs/002-tool-runtime-contract/` exist; +- accepted calls persist `contract_version`; legacy calls use an explicit + `legacy::` contract version and emit + `legacy_tool_context_resolved` compatibility telemetry; +- legacy deletion remains gated by zero observed legacy batches, one complete + supported-release interval, and a closed rollback window; +- the original dirty checkout remains separate from this worktree; +- no commit or push was performed. + +## Remaining Risks + +- Production/provider validation is not part of this local run. Deadline, + cancellation, unknown-write, credential revocation, and Provider Tool payload + behavior are covered by deterministic unit/integration doubles, not live + provider credentials. +- The current Runtime still settles accepted Tool Calls sequentially. The new + provider `parallel_tool_calls` capability only controls whether a Provider may + emit more than one call in a response; it does not authorize concurrent + business execution. `parallel_safe` remains a separate execution-policy fact. +- RegisteredTool migration is intentionally incremental. One builtin read, one + AgentBay read, and exact-name dynamic MCP contracts use the completeness gate; + remaining legacy adapters stay observable and hidden when incomplete until + their contracts are migrated and the deletion gate is satisfied. diff --git a/specs/002-tool-runtime-contract/research.md b/specs/002-tool-runtime-contract/research.md new file mode 100644 index 000000000..d8c1591f2 --- /dev/null +++ b/specs/002-tool-runtime-contract/research.md @@ -0,0 +1,89 @@ +# Phase 0 Research: Tool Runtime 契约与执行链路 + +## Baseline + +研究基线为 `upstream/main@251aeba8`。个人 fork 的 `origin/main@5aef9da4` 停留在 v1.10.1,不包含 Durable Runtime,因此本功能分支已无损快进到仓库上游主线。 + +当前已具备:LangGraph checkpoint、`AgentToolExecution` Receipt、`started/succeeded/failed/unknown`、safe-read bounded retry、lease owner/fence、async poll、unknown/reconcile、typed `ToolExecutionOutcome`、Provider transport retry 与全局 model turn limit。 + +当前缺口:Tool Step 再次调用 ToolProvider;checkpoint 无 Workset/Contract/Binding;Provider Call ID 直接作为 Runtime `tool_call_id`;参数只验证为 JSON object;failure envelope 和 repair episode 不完整;部分 IMAP/DNS/AgentBay 路径缺 deadline/cancel。 + +## Decisions + +### D1. Workset 在 Model Step 固化,Tool Step 不重建 + +**Decision**: Model Step 构建一次 Workset,并把已接受 Call 所需的最小 Tool Contract/Execution Binding 存入 checkpoint。 + +**Rationale**: `tool_step_service.py` 当前在执行时再次调用 `get_runtime_agent_tools_for_llm`,会让 assignment/enabled/readiness 的普通变化改变已经接受的调用。稳定 binding 可以跨 Worker 恢复,同时保留当前 actor/resource/credential/cancel 安全检查。 + +**Rejected**: 在 Tool Step 再调用 Provider 并比较两次结果。比较仍无法证明旧 endpoint/target,且会把普通 availability 当 hard revoke。 + +### D2. 保留现有 `tool_call_id` 为 Call Instance,另存 Provider ID + +**Decision**: 现有 `tool_call_id` 从 wire identity 收敛为 Clawith Call Instance;新增 nullable `provider_call_id`;`AgentToolExecution.id` 继续是 Execution/Receipt ID。 + +**Rationale**: 当前所有 Receipt、Activity、Chat、A2A 和 async poll 已围绕 `(run_id, tool_call_id)` 稳定工作。替换主键风险大,新增 Provider correlation 可兼容旧数据并允许不同 Assistant Turn 重复 Provider-local ID。 + +**Rejected**: 让 Provider ID 继续承担持久身份。Gemini/兼容 Provider 可能合成、缺失或重复 ID,跨 replay 不稳定。 + +### D3. Binding 保存引用和不可变目标,不保存秘密或可执行代码 + +**Decision**: Binding 保存 tool kind、registry key、handler key、MCP server/tool target、contract version 和 credential reference;执行时再读取当前 credential 并做安全授权。 + +**Rationale**: 既防止 endpoint/name 漂移,也允许 credential rotation/revocation 立即生效,不把秘密写入 checkpoint。 + +**Rejected**: checkpoint 保存解密 credential 或 Python callable。安全风险高且不具备跨版本可恢复性。 + +### D4. schema validation 位于 Receipt reservation 前 + +**Decision**: 使用 accepted schema 做通用结构校验,失败产生一个 Tool Result,但不创建执行 Receipt。 + +**Rationale**: 无效参数没有执行资格,不应消耗 provider attempt;同时必须返回模型可修复的、call-linked 反馈。 + +**Rejected**: 只依赖各 Handler 自行校验。错误格式不一致、授权/副作用前后顺序不可证明。 + +### D5. authorization 是统一 decision envelope,资源级检查可留在 adapter + +**Decision**: 所有工具经过同一 authorization/approval orchestration;只有必须读取真实对象的资源级判定可由 adapter 执行,但必须返回统一结果。 + +**Rationale**: 不强迫所有 provider 使用同一权限实现,同时保证结果语义和 Receipt 前门不可绕过。 + +### D6. 可修复失败返回模型,控制状态不伪装失败 + +**Decision**: 参数、binding 和确定性业务失败生成一个 sanitized Tool Result。Permission/confirmation、pending、cancel、unknown 和 checkpoint corruption 保持独立状态。 + +**Rationale**: 模型需要知道“为什么失败”和“可采取什么动作”,但 unknown write 绝不能诱导自动重试。 + +### D7. Repair budget 是 Tool episode,不是 Provider/Receipt retry + +**Decision**: 连续同 fingerprint 第 10 次、同 Tool episode 第 10 次暂停;只计模型可见、可修复失败。普通 Tool protocol repair、`write_file` protocol repair 和 safe-read replay 继续使用各自现有计数入口,但上限统一为 10,状态结构后续再整体重构。 + +**Rationale**: Provider transport retry 和 Receipt safe replay 都不代表模型做了错误决策;混计会过早停机或掩盖循环。 + +### D8. Deadline、cancel、lease 是三个控制面 + +**Decision**: 每个 operation 有 deadline;Run cancel 尽量传播到底层;lease 只证明 ownership,并通过 renew/fence 保护结算。 + +**Rationale**: lease 过期不等于 Handler timeout,timeout 也不证明外部写未发生。 + +### D9. Registry 渐进迁移 + +**Decision**: 先定义完整 RegisteredTool contract 和代表性 adapter,未完整声明能力的工具不进入新 Workset;不一次性搬迁 `agent_tools.py` 全部 Handler。 + +**Rationale**: 先消除执行漂移和失败盲区,再逐 family 收敛,降低回归面。 + +## Source Evidence + +- `model_step_service.py` 在每个模型轮构建 Runtime Workset并校验名称。 +- `tool_step_service.py` 在执行 pending calls 时再次调用 ToolProvider,是本次最直接的漂移来源。 +- `AgentToolExecution` 已提供 durable receipt、attempt、lease 和 unknown 状态,应该扩展而不是替换。 +- `tool_execution.py` 已提供 exact request comparison、safe-read retry、lease renewal/fence 和 reconciliation。 +- `node_executor.py` 现有 repair 主要按 protocol code 与 verifier 累计,不等于新的 Tool repair episode。 + +## Open Risks Resolved by Tests + +- Provider fallback 使用不同 capability Workset:最终接受 Call 必须绑定实际调用的 fallback Workset。 +- 同一 response 多 Call:每个 Call 独立 identity/binding,batch context 共享 Workset version。 +- Group/legacy hidden tools:只允许明确 compatibility path,不能让新模型轮重新暴露。 +- mixed Worker:nullable DB 字段和 checkpoint version discriminator 保证旧 Reader 不崩溃。 +- write 后断链:任何无法证明 outcome 的路径统一 unknown,不因 retryable 标记自动重放。 diff --git a/specs/002-tool-runtime-contract/spec.md b/specs/002-tool-runtime-contract/spec.md new file mode 100644 index 000000000..807f362de --- /dev/null +++ b/specs/002-tool-runtime-contract/spec.md @@ -0,0 +1,202 @@ +# Feature Specification: Tool Runtime 契约与执行链路修复 + +**Feature Branch**: `002-tool-runtime-contract` +**Created**: 2026-08-10 +**Status**: Draft +**Input**: 修复 Tool Runtime 中的工具集漂移、调用身份冲突、失败反馈缺失、修复次数混用,以及执行时限、取消和 Receipt lease 不协调的问题,并为长期统一 Tool Registry 建立兼容边界。 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 已接受的 Tool Call 稳定执行 (Priority: P1) + +作为使用 Agent 完成任务的用户,我希望模型已经基于本轮可见 Tool 做出的合法调用不会因为执行前工具配置被再次解析而意外失败,从而避免 Agent 在正确决策后仍中断任务。 + +**Why this priority**: 这是 CoAligne 评审确认的主问题。模型可见条件与执行条件不一致会直接降低 Agent 的任务完成率,并产生没有业务价值的额外修复轮次。 + +**Independent Test**: 模型产生一个当轮合法 Tool Call 后,在执行前修改该 Tool 的普通可见性配置;当前 Call 仍按原绑定执行,下一次模型决策则使用更新后的可见工具集合。 + +**Acceptance Scenarios**: + +1. **Given** 模型已收到当前可用 Tool 并返回一个名称合法的调用,**When** 调用进入执行阶段,**Then** 系统使用模型决策时已经确认的 Tool 绑定,不重新计算整套可见工具集合。 +2. **Given** 一个 Tool Call 已被当前模型轮次接受,**When** 管理员随后修改该 Tool 的普通 assignment、enabled 或 readiness 状态,**Then** 当前调用不因该普通可见性变化被拒绝,变化从下一次模型轮次生效。 +3. **Given** 当前 actor 已失去目标资源权限、凭证已撤销、精确 Tool 绑定已删除,或用户取消 Run,**When** 调用准备产生副作用,**Then** 系统按当前安全状态拒绝或取消执行,并给出明确结果,而不是继续执行或重新计算 Workset。 +4. **Given** Run 从 checkpoint 恢复,**When** 继续执行尚未完成的调用,**Then** 系统使用与原模型决策一致的调用绑定,不因恢复到另一 Worker 而改变 Tool 目标。 + +--- + +### User Story 2 - Tool 失败能够驱动模型修正 (Priority: P1) + +作为用户,我希望参数错误、明确业务拒绝或 Tool 绑定失效能够返回给模型,让模型修改参数、改选 Tool 或向我提问,而不是直接终止整个 Run。 + +**Why this priority**: 当前部分失败只保留普通文本,部分执行前失败直接终止 Run。没有统一、可操作的失败反馈,就无法建立可靠的 Agent 自主修复循环。 + +**Independent Test**: 让模型提交一个带有效调用身份但参数不合法的 Tool Call;系统返回一个安全、结构化、与原调用关联的失败;模型修正参数后再次调用并成功完成任务。 + +**Acceptance Scenarios**: + +1. **Given** Tool Call 具有有效身份但参数不符合 Tool 要求,**When** 系统在 Handler 前发现错误,**Then** 模型恰好收到一个与原调用关联的失败结果,包含稳定错误码、可操作摘要和建议动作。 +2. **Given** Tool 或外部服务明确拒绝请求且确认未产生不确定副作用,**When** 系统处理结果,**Then** 模型可以看到经过清洗的拒绝原因并决定修正或改选 Tool。 +3. **Given** 失败信息包含密钥、完整敏感参数、原始异常或大段 Provider 响应,**When** 生成模型反馈,**Then** 敏感内容被删除或脱敏,只保留有界、可操作的信息。 +4. **Given** 外部写操作可能已经发生但结果无法确认,**When** 系统处理该结果,**Then** Run 进入等待协调状态,模型和 Runtime 都不得将其当作普通可重试失败。 +5. **Given** Tool Call 身份缺失、在同一模型响应中重复,或消息交换关系不可能成立,**When** 系统校验调用,**Then** 系统不得伪造调用身份或执行 Handler,并以协议错误结束该路径。 + +--- + +### User Story 3 - 多轮 Tool Call 身份不会碰撞 (Priority: P1) + +作为连续多轮使用 Tool 的用户,我希望不同模型轮次即使收到 Provider 重复使用的局部 Call ID,也不会复用错误的执行记录、覆盖结果或中断 Run。 + +**Why this priority**: 已确认 Gemini 会在每次响应中从 `call_1` 开始编号,而当前耐久执行记录把该值当作 Run 内全局身份。这是一条确定性的多轮失败路径。 + +**Independent Test**: 在同一个 Run 中连续两个模型轮次分别调用不同 Tool,但 Provider 都返回 `call_1`;两个调用独立执行、独立记录,并分别与正确的 Tool Result 配对。 + +**Acceptance Scenarios**: + +1. **Given** 同一 Run 的两个不同 Assistant Turn 都包含 Provider-local `call_1`,**When** 系统执行它们,**Then** 两次调用拥有不同的 Clawith 调用实例和执行记录。 +2. **Given** 同一个 Assistant Tool Call 因 checkpoint replay 再次进入执行,**When** Runtime 恢复,**Then** 它复用原执行记录,不重复产生副作用。 +3. **Given** 同一 Assistant Turn 中存在重复 Call ID,**When** 系统准备执行,**Then** 在任何 Receipt 或副作用产生前拒绝整个非法交换。 +4. **Given** 多轮历史包含相同 Provider-local Call ID,**When** 生成 Tool Result、Activity、Chat、A2A correlation 或下一次 Provider 请求,**Then** 每个结果仍与正确调用实例关联。 + +--- + +### User Story 4 - 修复循环有独立且可理解的预算 (Priority: P2) + +作为用户,我希望 Agent 可以多次修正真正可修复的 Tool 错误,但在持续重复同一错误或围绕同一个 Tool 打转时及时暂停,并允许我纠正后重新开始计数。 + +**Why this priority**: 当前全局模型轮次、Provider retry、Tool replay、Verifier repair 和模型修复容易被混为一种“重试”。独立预算可以兼顾自主完成率、成本和安全。 + +**Independent Test**: 分别制造连续相同错误、同 Tool 不同错误、成功后再次失败、用户纠正后恢复,以及不应计数的 pending/permission/unknown-write 事件,验证每种计数和重置边界。 + +**Acceptance Scenarios**: + +1. **Given** 同一稳定错误已经连续作为模型可见失败出现 9 次,**When** 第 10 次相同失败被记录,**Then** 系统保存该失败并暂停,不能开始第 11 次模型调用。 +2. **Given** 同一个 Tool 在当前 episode 中出现 9 次可计数失败,错误指纹可以变化,**When** 第 10 次失败被记录,**Then** 系统暂停,不能开始下一次模型调用。 +3. **Given** 失败指纹变化但 Tool 相同,**When** 记录新失败,**Then** 连续相同错误计数重新开始,但同 Tool episode 总数保留。 +4. **Given** 被跟踪 Tool 成功、新 Run 开始,或用户明确纠正后恢复,**When** 后续再发生失败,**Then** 按对应规则开启新的 repair episode。 +5. **Given** 事件属于 Provider transport retry、安全内部 replay、permission/confirmation wait、async pending、cancel 或 unknown external write,**When** 系统处理事件,**Then** 不增加模型修复计数。 +6. **Given** 全局 Run 模型轮次已经达到上限,**When** 本地 Tool repair budget 尚未耗尽,**Then** 全局上限仍独立生效并展示不同的停止原因。 +7. **Given** 普通 Tool 或 `write_file` 的 arguments JSON 无效或截断,**When** Runtime 请求模型修复,**Then** 两类 Tool 都分别最多提供 10 次重写机会;safe-read Runtime replay 最多执行同一调用 10 次。本轮只统一上限数值,不重构这些独立计数器。 + +--- + +### User Story 5 - 长时间 Tool 执行可控且可恢复 (Priority: P2) + +作为用户,我希望网络、邮箱、云桌面和代码执行不会无限等待;取消 Run 能尽可能停止正在进行的操作;Worker lease 变化也不会被误认为 Handler 已超时或已取消。 + +**Why this priority**: 执行时限、用户取消和 Receipt lease 是三种不同机制。混用它们会造成无法终止的操作、错误重试或执行完成后无法结算。 + +**Independent Test**: 对选定的网络读取、邮箱读取、云桌面读取和长时间代码执行分别制造超时、取消、lease renewal 和 lease loss,验证底层操作、结果分类和副作用安全。 + +**Acceptance Scenarios**: + +1. **Given** 一个外部读取操作超过该操作允许的最长等待,**When** deadline 到达,**Then** Agent loop 停止等待,并在底层能力支持时终止对应网络、进程或 SDK 操作。 +2. **Given** 用户取消正在执行的 Run,**When** Handler 或 backend 支持取消,**Then** 取消信号传递到底层操作,并停止继续续租。 +3. **Given** 一个合法 Handler 的运行时间超过默认 Receipt lease,**When** 当前 Worker 仍然健康且拥有执行权,**Then** lease 被续期,其他 Worker 不能并发接管同一执行。 +4. **Given** Handler 在可能产生外部写之后发生 deadline、取消或连接中断,**When** 无法证明最终结果,**Then** 状态为 unknown/reconcile,系统不得自动重放。 +5. **Given** Tool 有显式时限、配置默认时限和最大时限,**When** 用户省略或提供时限,**Then** 系统按“显式值优先、否则配置默认值、最后受最大值限制”的规则执行。 + +--- + +### User Story 6 - 旧 Run 可兼容,长期 Tool 注册可渐进迁移 (Priority: P3) + +作为平台维护者,我希望升级后仍能恢复受支持的旧 checkpoint,同时新的 Tool 定义、Handler、授权和恢复能力逐步收敛到同一注册来源,避免再次发生能力发布遗漏。 + +**Why this priority**: 直接删除旧路径会影响运行中的 Run;一次性迁移全部 Tool 又风险过高。需要可观测、可删除的兼容层和分批迁移边界。 + +**Independent Test**: 使用没有新 Tool context 的历史 checkpoint 恢复执行,并验证兼容路径、日志、结果语义和清理条件;同时验证新注册项缺少 Schema、Handler 或安全声明时无法对模型开放。 + +**Acceptance Scenarios**: + +1. **Given** 旧 checkpoint 只有 pending Tool Calls,**When** 新版本恢复它,**Then** 使用明确标识的 legacy compatibility path,并且同一 pending batch 不为每个 Call 重建一次 Tool 环境。 +2. **Given** 旧 checkpoint 中的合法 Call 已无法执行,**When** 统一失败反馈能力启用后,**Then** 模型收到一个 legacy binding unavailable 结果,而不是无原因 terminal。 +3. **Given** 新 checkpoint 的 Tool context 与 pending Call 不匹配,**When** 准备执行,**Then** 在 Receipt 前按 context corruption 拒绝,不能猜测绑定。 +4. **Given** 一个 Tool 注册项缺少模型定义、可执行 Handler 或必要安全属性,**When** 系统准备将其加入 Workset,**Then** 该注册项被拒绝并给出可诊断原因。 +5. **Given** legacy compatibility 使用量在完整保留周期和回滚窗口内持续为零,**When** restore 测试也证明没有依赖,**Then** 兼容路径才可以被删除。 + +### Edge Cases + +- 同一个模型响应中包含多个 Tool Call,其中前一个已成功产生副作用,后一个发生参数、授权或 binding 失败。 +- 模型响应中的 Call ID 为空、重复、长度异常,或 Tool name 不在当轮可见集合。 +- Model Step 完成后进程崩溃,Tool Step 在另一 Worker 上从 checkpoint 恢复。 +- MCP Tool 在 Model Step 后被重命名、删除、迁移到其他 tenant、修改 endpoint,或只轮换 credential。 +- 管理员关闭 Tool,但当前已接受调用仍在等待人工确认;用户随后拒绝、接受或取消。 +- Safe-read 内部 retry 已耗尽,最终只应产生一次模型可见失败和一次 repair 计数。 +- Tool Result 已写入 checkpoint,但节点被重新调度;结果消息和 repair counter 不能重复追加。 +- 同一 Tool 在不同错误之间交替,连续相同错误计数不断重置,但同 Tool episode 最终达到 10。 +- Unknown external write 在重启、重连、用户输入或模型继续推理时仍不得自动重放。 +- Handler 完成时 lease 已丢失;旧 owner 不能覆盖新 owner 或绕过 fence 结算。 +- 底层线程调用无法真正取消;系统必须停止等待并明确记录底层取消能力限制。 +- 旧 Worker 与新 Worker 同时运行时,新的调用实例身份不能提前允许 Provider-local ID 重复。 + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 系统 MUST 在每个模型决策轮次只构建一次模型可见 Tool 集合,并用同一集合完成 Tool name 校验。 +- **FR-002**: 系统 MUST 为每个已接受 Tool Call 保存其所属 Assistant Turn、Provider Call ID、Tool 名称、Tool Contract 版本以及可恢复的执行绑定。 +- **FR-003**: 新格式 checkpoint 的 Tool 执行 MUST 使用已保存绑定,且 MUST NOT 通过重新计算 Agent assignment、enabled、channel 或 readiness 来决定当前 Call 是否可执行。 +- **FR-004**: 普通 Tool availability 变化 MUST 从下一次模型轮次生效;当前已接受 Call 仍 MUST 接受当前 actor、tenant、目标资源、credential 和 cancel 状态检查。 +- **FR-005**: 系统 MUST 为 Provider Call、Clawith 调用实例、执行记录和业务幂等分别维护不会混用的身份。 +- **FR-006**: 同一 Assistant Turn 内 Provider Call ID MUST 唯一;不同 Assistant Turn MAY 使用相同 Provider-local ID,且不得产生内部碰撞。 +- **FR-007**: 同一调用实例的 checkpoint replay MUST 复用原执行记录;不同调用实例 MUST NOT 复用执行结果或副作用记录。 +- **FR-008**: 所有内部结果消息、Activity、Chat、异步操作和 A2A correlation MUST 使用调用实例身份关联;Provider wire output MUST 保留原 Provider Call ID。 +- **FR-009**: 系统 MUST 在 Handler 前验证输入是合法对象,并符合该 Call 已接受的 Tool 参数要求。 +- **FR-010**: Typed builtin、legacy adapter、MCP、A2A、group 和 AgentBay 调用 MUST 经过同一个不可绕过的授权/审批入口后才能预留执行并产生副作用。 +- **FR-011**: 依赖真实资源状态、只能在 Handler 内完成的对象级授权 MUST 返回统一、可分类的 authorization result。 +- **FR-012**: 对具有有效调用身份的可修复失败,系统 MUST 生成恰好一个与原 Call 关联的 Tool Result。 +- **FR-013**: 模型可见失败 MUST 包含执行状态、稳定错误码、有界摘要、模型可采取的动作、副作用确定性,以及可选安全修复提示。 +- **FR-014**: 模型可见失败 MUST 删除或脱敏 secrets、完整敏感参数、stack trace、未清洗异常和无界 Provider payload。 +- **FR-015**: Permission/confirmation、async pending、cancel、unknown external write 和协议损坏 MUST 使用各自独立状态,不得伪装成普通可修复 Tool failure。 +- **FR-016**: Unknown possible write MUST 阻止自动重放,直到通过外部查询、稳定幂等结果或明确人工处理完成协调。 +- **FR-017**: 系统 MUST 在第 10 次连续相同且模型可见的可修复失败后暂停,并且 MUST NOT 启动第 11 次模型调用。 +- **FR-018**: 系统 MUST 在同一个 Tool repair episode 的第 10 次可计数失败后暂停,并且 MUST NOT 启动下一次模型调用。 +- **FR-019**: 不同错误指纹 MUST 只重置连续相同错误计数,不得清除同 Tool episode 总数。 +- **FR-020**: 对应 Tool 成功、新 Run 或用户明确纠正后恢复 MUST 按定义重置 repair episode;无关 Tool 成功不得清除其他 Tool 的失败 episode。 +- **FR-021**: Provider transport retry、安全内部 replay、permission/confirmation wait、async pending、cancel 和 unknown external write MUST NOT 增加模型修复计数。普通 Tool protocol repair、`write_file` protocol repair 和 safe-read replay 保留独立计数结构,但各自上限 MUST 统一为 10;计数结构重构不属于本轮改动。 +- **FR-022**: 全局模型轮次上限 MUST 与 Tool repair budget、Provider retry、Command retry 和 Verifier repair 保持独立,并报告不同停止原因。 +- **FR-023**: Verifier repair MUST 按当前问题 episode 计数;历史已结束问题不得耗尽新的 verifier episode。 +- **FR-024**: 外部 I/O 和长时间操作 MUST 具有与具体操作匹配的最长等待规则;系统 MUST NOT 用单一固定秒数替代所有 Tool 的时限。 +- **FR-025**: 用户取消 MUST 尽可能传播到底层进程、网络或 SDK 操作;不支持强制取消时 MUST 明确记录该限制。 +- **FR-026**: 长时间执行 MUST 在仍拥有执行权时维护 Receipt ownership;lease 到期 MUST NOT 被解释为 Handler 已超时或已取消。 +- **FR-027**: 发生 deadline、cancel 或连接中断后,只要外部写结果无法证明,系统 MUST 将结果标记为 unknown/reconcile。 +- **FR-028**: Tool 显式时限、配置默认时限和最大时限 MUST 遵循一致且可验证的优先级。 +- **FR-029**: 旧 checkpoint MUST 通过明确、可观测且有删除条件的 compatibility path 恢复;新 checkpoint MUST NOT 使用该路径。 +- **FR-030**: 系统 MUST 记录 Workset/Tool Contract 版本、legacy fallback、失败处置、repair counter transition、执行时长、deadline、cancel 和 lease 事件,且不得记录原始秘密。 +- **FR-031**: 长期 Tool 注册来源 MUST 能够把模型定义、Handler、副作用、retry、authorization、recovery 和执行控制能力关联到同一稳定身份。 +- **FR-032**: 未完整迁移的 Tool MUST 保持隐藏;系统 MUST NOT 仅因为存在 legacy Handler 就将其暴露给模型。 + +### Key Entities + +- **Tool Workset**: 某一模型决策轮次真正允许模型看到和选择的 Tool 集合,包含每个 Tool 的模型定义、稳定绑定引用、版本和可用性决定。 +- **Step Tool Context**: 随 checkpoint 保存的本轮 Tool 上下文,关联 Assistant Turn、Workset 版本、已接受 Call、Tool Contract 和执行绑定。 +- **Provider Call Identity**: Provider 在单个模型响应内提供的 Tool Call 标识,只用于协议配对。 +- **Call Instance**: Clawith 对一次具体 Assistant Tool Call 建立的 Run 内稳定身份,用于跨 checkpoint、消息、Activity、异步操作和 A2A 关联。 +- **Tool Execution Receipt**: 一次调用实例的耐久执行记录,保存执行状态、ownership、尝试次数、副作用分类、结果和协调信息。 +- **Tool Result**: 返回给模型和 Runtime 的标准化执行结果,包含成功、失败、pending 或 unknown 状态及安全反馈。 +- **Repair Episode**: 某个 Tool 或 Verifier 问题的一段连续修复过程,拥有独立计数、错误指纹和重置边界。 +- **Execution Binding**: Model Step 已接受的具体 Tool 执行目标,不包含可执行代码或解密凭证,但足以在恢复时解析相同 Handler/Provider target。 + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 在覆盖普通 availability 变化、checkpoint restart 和跨 Worker 恢复的测试矩阵中,100% 已接受 Tool Call 使用原模型轮次绑定执行;新格式 Tool Step 的 Workset 二次解析次数为 0。 +- **SC-002**: 在所有受支持 Provider 的多轮 Tool 场景中,重复的 Provider-local Call ID 产生 0 次执行记录、Tool Result、Activity、Chat 或 A2A correlation 碰撞。 +- **SC-003**: 同一调用实例在至少一次 checkpoint replay 后仍只产生一条有效执行记录;未知外部写的自动重放次数为 0。 +- **SC-004**: 100% 带有效身份的可修复参数、binding 和明确业务失败产生恰好一个模型可见 Tool Result;敏感信息泄漏测试通过率为 100%。 +- **SC-005**: 第 10 次连续相同错误和第 10 次同 Tool episode 失败均在规定边界暂停;普通 Tool JSON repair、`write_file` JSON repair 和 safe-read replay 的独立上限均为 10;所有 off-by-one、reset 和 exclusion 测试通过率为 100%。 +- **SC-006**: Permission、confirmation、pending、cancel、unknown write、Provider retry 和全局模型轮次上限均显示独立原因,测试中不存在跨预算误计数。 +- **SC-007**: 所有列入范围的 IMAP、DNS、AgentBay read 和代码执行路径在配置的最长等待内返回结果或明确状态,不产生无限等待测试用例。 +- **SC-008**: 长时间 Handler 的 lease renewal、lease loss 和 cancel 测试均不会产生并发双执行或旧 owner 越权结算。 +- **SC-009**: 所有受支持旧 checkpoint 可以通过 compatibility fixture 恢复;新 checkpoint 使用 legacy fallback 的次数为 0。 +- **SC-010**: Tool Runtime 相关回归测试、静态检查和涉及的前端构建全部通过,且原有 unknown-write、Receipt replay 和 Provider Receipt 安全断言没有被弱化。 + +## Assumptions + +- 现有耐久 Tool Receipt、safe-read bounded retry、pending 和 unknown/reconcile 机制继续作为执行安全基础,不在本功能中删除。 +- 普通 assignment、enabled 和 readiness 是模型侧可用性,不被当作已接受 Call 的紧急撤销信号。 +- 立即停止当前调用依赖耐久 Run cancel,或撤销底层 actor、资源或 credential 权限;通用 Tool hard-revoke 数据模型不属于本功能。 +- 完整 Provider Schema capability matrix、默认 Tool 集合收窄、通用 Tool Search 和通用并行执行不属于本功能。 +- 未迁移的 AgentBay Action 继续保持隐藏,后续按 Tool family 分批迁移。 +- 旧 checkpoint 兼容路径只在有观测证据证明不再使用后删除。 +- 用户已经确定所有 Tool 相关 repair/retry 上限统一为 10,并保留独立的计数结构与全局 Run 模型轮次上限;计数结构后续统一重构。 diff --git a/specs/002-tool-runtime-contract/tasks.md b/specs/002-tool-runtime-contract/tasks.md new file mode 100644 index 000000000..965a3ab40 --- /dev/null +++ b/specs/002-tool-runtime-contract/tasks.md @@ -0,0 +1,249 @@ +# Tasks: Tool Runtime 契约与执行链路修复 + +**Input**: Design documents from `/specs/002-tool-runtime-contract/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md +**Tests**: 规范明确要求 Runtime contract、replay、repair budget、deadline/cancel/lease 与兼容测试;各故事按 test-first 执行。 + +## Phase 1: Setup + +**Purpose**: 固定基线、测试入口和迁移拓扑。 + +- [x] T001 记录 `upstream/main` 基线、branch 和 dirty state 到 `specs/002-tool-runtime-contract/quickstart.md` +- [x] T002 [P] 核对并记录现有 Tool Runtime 定向测试清单到 `specs/002-tool-runtime-contract/quickstart.md` +- [x] T003 [P] 运行 Alembic single-head 与 `scripts/arch-guard.sh` 基线检查并记录结果到 `specs/002-tool-runtime-contract/quickstart.md` + +--- + +## Phase 2: Foundational Contracts + +**Purpose**: 建立所有故事共享的 checkpoint-safe contract 与兼容边界。 + +**⚠️ CRITICAL**: 本阶段完成前不修改 Model/Tool 执行主链。 + +- [x] T004 [P] 为 `StepToolContext`、`ToolWorksetEntry`、`AcceptedToolCall` 编写失败态 contract tests 于 `backend/tests/test_agent_runtime_tool_contracts.py` +- [x] T005 [P] 为三层身份与 legacy JSON compatibility 编写失败态 tests 于 `backend/tests/test_agent_runtime_tool_contracts.py` +- [x] T006 实现 versioned、bounded、secret-free Tool contracts 于 `backend/app/services/agent_runtime/tool_contracts.py` +- [x] T007 将 `step_tool_context` 与 `tool_repair_episodes` 类型接入 `backend/app/services/agent_runtime/state.py` +- [x] T008 在 `backend/tests/test_agent_runtime_contracts.py` 增加 checkpoint 序列化/旧 state 兼容测试 + +**Checkpoint**: Contract 可独立序列化,旧 checkpoint 仍可读取。 + +--- + +## Phase 3: User Story 1 — 已接受的 Tool Call 稳定执行 (Priority: P1) 🎯 MVP + +**Goal**: Model Step 固化 Workset/Binding;新 Tool Step 不再重建 Workset。 + +**Independent Test**: 接受 Tool Call 后修改 assignment/enabled/readiness 并换 Worker 恢复,当前 Call 仍使用原 binding;actor/resource/credential/cancel 变化仍阻断。 + +### Tests + +- [x] T009 [P] [US1] 在 `backend/tests/test_agent_runtime_model_step_service.py` 增加实际 primary/fallback Workset 固化与 stable Call Instance 测试 +- [x] T010 [P] [US1] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加新 checkpoint ToolProvider 调用为 0、availability 漂移不影响当前 Call 测试 +- [x] T011 [P] [US1] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 context mismatch/corruption 在 Receipt 前失败测试 +- [x] T012 [P] [US1] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 actor/resource/credential/cancel 仍按当前状态阻断测试 + +### Implementation + +- [x] T013 [US1] 在 `backend/app/services/agent_runtime/model_step_service.py` 生成实际 Provider Workset 的 `StepToolContext` 与稳定 Call Instance +- [x] T014 [US1] 在 `backend/app/services/agent_runtime/node_executor.py` 原子写入 assistant message、pending calls 与 `step_tool_context` +- [x] T015 [US1] 在 `backend/app/services/agent_runtime/tool_step_service.py` 从 context 解析 accepted schema/policy/binding 并移除新格式 ToolProvider 查询 +- [x] T016 [US1] 在 `backend/app/services/agent_runtime/tool_step_service.py` 实现整批一次的 legacy resolver 与可观测 compatibility marker +- [x] T017 [US1] 在 `backend/app/services/agent_runtime/tool_step_service.py` 保留当前安全 authorization/cancel gate 并阻止 context corruption fallback + +**Checkpoint**: US1 测试独立通过,SC-001 达成。 + +--- + +## Phase 4: User Story 2 — Tool 失败能够驱动模型修正 (Priority: P1) + +**Goal**: 在 Receipt 前统一 schema validation;有效 Call 的可修复失败产生一个 sanitized Tool Result。 + +**Independent Test**: 缺 required 字段、类型错误、binding unavailable 和确定性业务拒绝分别返回 call-linked failure envelope;secret/stack/provider body 不可见。 + +### Tests + +- [x] T018 [P] [US2] 在 `backend/tests/test_agent_runtime_tool_validation.py` 增加 object/required/type/enum/additionalProperties contract tests +- [x] T019 [P] [US2] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 schema failure 不创建 Receipt且恰好一个 Tool Result 测试 +- [x] T020 [P] [US2] 在 `backend/tests/test_agent_runtime_tool_outcome_contract.py` 增加 model_action/side_effect_state/remediation 与 redaction 测试 +- [x] T021 [P] [US2] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 permission/confirmation/pending/cancel/unknown 不伪装普通失败测试 + +### Implementation + +- [x] T022 [US2] 实现 accepted-schema validator 于 `backend/app/services/agent_runtime/tool_validation.py` +- [x] T023 [US2] 扩展 bounded `ToolExecutionOutcome`/Tool Result envelope 于 `backend/app/services/agent_runtime/tool_execution.py` +- [x] T024 [US2] 在 `backend/app/services/agent_runtime/tool_step_service.py` 接入 Receipt 前 validation 和 exactly-once failure message +- [x] T025 [US2] 在 `backend/app/services/agent_runtime/checkpoint_side_effects.py` 投影 execution/call/provider identity 与 sanitized failure metadata + +**Checkpoint**: US2 测试独立通过,SC-004 达成。 + +--- + +## Phase 5: User Story 3 — 调用身份与 Receipt 不冲突 (Priority: P1) + +**Goal**: Provider Call、Call Instance、Execution Receipt 和业务幂等身份职责分离。 + +**Independent Test**: 不同 Assistant Turn 使用相同 Provider-local ID 时,产生两个 Call Instance/Receipt;同一 Call replay 只复用原 execution。 + +### Tests + +- [x] T026 [P] [US3] 在 `backend/tests/test_agent_runtime_tool_execution.py` 增加 provider ID 重复与 Call Instance 唯一性测试 +- [x] T027 [P] [US3] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 checkpoint replay 复用 execution 且不重复副作用测试 +- [x] T028 [P] [US3] 在 `backend/tests/test_agent_runtime_checkpoint_side_effects.py` 增加 Activity/Chat/A2A identity projection 测试 +- [x] T029 [P] [US3] 在 `backend/tests/test_agent_runtime_tool_execution_migration.py` 增加 nullable 新字段 upgrade/downgrade 与旧行兼容测试 + +### Implementation + +- [x] T030 [US3] 扩展 `provider_call_id` 与 `contract_version` 字段于 `backend/app/models/agent_tool_execution.py` +- [x] T031 [US3] 创建 single-head、DDL-only、可回滚 migration 于 `backend/alembic/versions/` +- [x] T032 [US3] 在 `backend/app/services/agent_runtime/tool_execution.py` reservation/exact request/outcome 中保存并校验 Provider correlation 和 contract version +- [x] T033 [US3] 在 `backend/app/services/agent_runtime/model_step_service.py`、`backend/app/services/agent_runtime/tool_step_service.py` 保留 Provider wire pairing 并内部使用 Call Instance +- [x] T034 [US3] 在 `backend/app/services/agent_runtime/async_tool_poll.py`、`backend/app/services/agent_runtime/a2a_runtime.py` 和 `backend/app/services/agent_runtime/checkpoint_side_effects.py` 透传三层 identity + +**Checkpoint**: US3 测试独立通过,SC-002/SC-003 达成。 + +--- + +## Phase 6: User Story 4 — 修复次数按问题边界计算 (Priority: P2) + +**Goal**: 实现连续同错 10、同 Tool episode 10,并将现有独立 Tool repair/retry 上限统一为 10;本轮不重构计数结构。 + +**Independent Test**: 统一上限 10 的边界、fingerprint 变化、Tool success、新 Run、用户纠正、无关 Tool success及所有 exclusion 均按 contract 转移。 + +### Tests + +- [x] T035 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加统一上限 10 的 off-by-one 与 fingerprint 测试 +- [x] T036 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加 success/new Run/user correction/reset scope 测试 +- [x] T037 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加 Provider retry/safe replay/approval/pending/cancel/unknown exclusion 测试 +- [x] T038 [P] [US4] 在 `backend/tests/test_agent_runtime_node_executor.py` 增加暂停发生在下一次 Model 调用之前的集成测试 +- [x] T039 [P] [US4] 在 `backend/tests/test_agent_runtime_node_executor.py` 增加 Verifier issue episode 与全局 model turn limit 独立测试 + +### Implementation + +- [x] T040 [US4] 实现纯函数 repair episode transitions 于 `backend/app/services/agent_runtime/tool_repair_budget.py` +- [x] T041 [US4] 在 `backend/app/services/agent_runtime/node_executor.py` 对 Tool Result 应用 episode、暂停与 stop reason +- [x] T042 [US4] 在 `backend/app/services/agent_runtime/node_executor.py` 将 verifier aggregate count 迁为 issue fingerprint episode +- [x] T043 [US4] 在 `backend/app/services/agent_runtime/model_step_service.py` 标识 explicit user correction reset 边界并记录 telemetry + +**Checkpoint**: US4 测试独立通过,SC-005/SC-006 达成。 + +--- + +## Phase 7: User Story 5 — 长时间 Tool 可控且不会被错误重放 (Priority: P2) + +**Goal**: operation deadline、durable cancel 和 Receipt lease 独立且可验证。 + +**Independent Test**: IMAP、DNS、AgentBay read/code、本地 code 在策略时限内结束;取消尽可能传播;lease loss 阻止旧 owner;不确定写不重放。 + +### Tests + +- [x] T044 [P] [US5] 在 `backend/tests/test_agent_tools_deadlines.py` 增加 IMAP/DNS/AgentBay read/code deadline 优先级测试 +- [x] T045 [P] [US5] 在 `backend/tests/test_agent_runtime_cancel_source.py` 增加 cancel token 传播与不支持 hard-cancel telemetry 测试 +- [x] T046 [P] [US5] 在 `backend/tests/test_agent_runtime_tool_execution.py` 增加 lease renew/loss/fence 和 stale settlement 测试 +- [x] T047 [P] [US5] 在 `backend/tests/test_agent_runtime_tool_outcome_contract.py` 增加 deadline/cancel 后 possible write → unknown/no replay 测试 + +### Implementation + +- [x] T048 [US5] 定义 deadline/cancel capability policy 于 `backend/app/services/agent_runtime/tool_contracts.py` +- [x] T049 [US5] 在 `backend/app/services/agent_tools.py` 为 IMAP、DNS、本地 code 接入 bounded deadline 和正确 outcome classification +- [x] T050 [US5] 在 `backend/app/services/agentbay_client.py` 与 `backend/app/services/agent_tools.py` 实际执行 AgentBay read/code timeout +- [x] T051 [US5] 在 `backend/app/services/agent_runtime/tool_step_service.py` 运行长任务 lease renewal 并在 settlement 前 fence +- [x] T052 [US5] 在 `backend/app/services/agent_runtime/cancel_source.py` 和 Tool adapter 接口传播 cancel token/capability telemetry + +**Checkpoint**: US5 测试独立通过,SC-007/SC-008 达成。 + +--- + +## Phase 8: User Story 6 — 旧 Run 兼容与长期 Tool 注册迁移 (Priority: P3) + +**Goal**: 旧 checkpoint 可观测恢复;新 RegisteredTool 不完整时不可暴露。 + +**Independent Test**: legacy fixtures 恢复且每 batch 只解析一次;新 checkpoint 永不 fallback;不完整 Registry entry 被拒绝;代表性 builtin/MCP/AgentBay read 可执行。 + +### Tests + +- [x] T053 [P] [US6] 在 `backend/tests/test_agent_runtime_tool_step_service.py` 增加 legacy batch resolver、telemetry 与 deletion gate fixtures +- [x] T054 [P] [US6] 在 `backend/tests/test_builtin_tool_contracts.py` 增加 RegisteredTool completeness/hidden-by-default tests +- [x] T055 [P] [US6] 在 `backend/tests/test_agent_tools_legacy_contract_compatibility.py` 增加代表性 builtin/MCP/AgentBay adapter tests + +### Implementation + +- [x] T056 [US6] 定义 `RegisteredTool` completeness gate 与 lookup 于 `backend/app/services/agent_runtime/tool_registry.py` +- [x] T057 [US6] 将一个 builtin、一个 MCP 和一个 AgentBay read 注册到 `backend/app/services/agent_runtime/tool_registry.py` +- [x] T058 [US6] 在 `backend/app/services/agent_tools.py` 保留明确 legacy adapter 并隐藏不完整注册项 +- [x] T059 [US6] 在 `backend/app/services/agent_runtime/tool_step_service.py` 增加 legacy usage metric/log 和删除条件 + +**Checkpoint**: US6 测试独立通过,SC-009 达成。 + +--- + +## Phase 9: Polish & Cross-Cutting Verification + +- [x] T060 [P] 修复 `set_trigger` validation、`read_document` truncation 和 invisible Tool description 漂移于 `backend/app/services/agent_tools.py` 及对应 tests +- [x] T061 [P] 将 provider `parallel_tool_calls` capability 与业务并行执行能力分离于 `backend/app/services/llm/` 及对应 tests +- [x] T062 运行并修复 scoped Ruff 与 Tool Runtime pytest,命令记录到 `specs/002-tool-runtime-contract/quickstart.md` +- [x] T063 运行 Alembic heads、upgrade/downgrade 和 migration tests,结果记录到 `specs/002-tool-runtime-contract/quickstart.md` +- [x] T064 运行 `scripts/arch-guard.sh`、全量 `backend/tests/test_agent_runtime_*.py` 并记录剩余风险到 `specs/002-tool-runtime-contract/quickstart.md` +- [x] T065 核对所有 docs path、contract/version、legacy deletion gate 与 `git diff --check`,更新 `specs/002-tool-runtime-contract/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup → Foundational Contracts → US1。 +- US2 依赖 US1 的 accepted schema/binding。 +- US3 依赖 US1 的 stable Call Instance,但其 DB migration/tests 可与 US2 tests 并行。 +- US4 依赖 US2 的标准 failure envelope。 +- US5 依赖 US1/US3 的 binding/Receipt identity,不依赖 US4。 +- US6 依赖 US1 的 compatibility boundary,代表性 Registry 可在 US4/US5 后独立完成。 +- Polish 依赖所选故事完成。 + +### User Story Completion Order + +```text +US1 stable context +├── US2 validation/failure ──> US4 repair budget +├── US3 identity ────────────> US5 lifecycle hardening +└── US6 registry compatibility +``` + +### Parallel Opportunities + +- 每个故事的 `[P]` tests 可在不同文件并行准备,但必须先失败再实现。 +- US2 outcome contract tests 与 US3 migration tests 可并行。 +- US4 pure transition module与 US5 operation-specific handler tests 可在 US3 完成后并行。 +- T060/T061 彼此独立,最后统一回归。 + +## Parallel Example: User Story 1 + +```text +T009 Model Step context tests +T010 ToolProvider-zero and availability drift tests +T011 corruption tests +T012 live safety revocation tests +``` + +## Implementation Strategy + +### MVP First + +1. 完成 T001–T008 固定 contract。 +2. 完成 T009–T017,交付稳定 Workset/Binding 的 US1。 +3. 独立验证 SC-001 后再进入 failure/identity。 + +### Incremental Delivery + +1. US1 消除已接受 Call 的 Workset 漂移。 +2. US2/US3 补齐模型可修复反馈和身份兼容。 +3. US4 落地统一上限 10 的修复次数,保留现有独立计数结构。 +4. US5 加固长任务生命周期。 +5. US6 建立长期 Registry 迁移边界。 + +## Notes + +- 所有 Runtime 行为改动先写失败测试,再修改生产代码。 +- `Tool Step ToolProvider calls = 0` 只适用于新 checkpoint;legacy batch 允许恰好一次。 +- 不把 Receipt lease、operation deadline 或 durable cancel 合并成单一 timeout。 +- 不把长期 Registry 扩展为一次性迁移全部工具。