diff --git a/docker-compose.yml b/docker-compose.yml index cbeec1e372..8dfec4f13b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,11 @@ services: ports: - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" volumes: + # Live host checkout for repository maintenance. The running app remains + # the image snapshot at /app. + - .:/project/workspace + # Disposable Linux-volume checkout for sandbox-first patch experiments. + - odysseus-workbench:/project/workbench - ${APP_DATA_DIR:-./data}:/app/data:z - ${APP_LOGS_DIR:-./logs}:/app/logs:z # Cookbook remote-server SSH identity. Odysseus can generate a key here; @@ -149,6 +154,7 @@ services: restart: unless-stopped volumes: + odysseus-workbench: searxng-data: chromadb-data: ntfy-cache: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index aec3b8eec5..fab268a030 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -96,7 +96,7 @@ repair_bind_mount_ownership() { # Repair image-owned writable paths without walking into bind-mounted host # trees, then repair the app-owned mount roots separately. repair_app_tree_ownership -for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local; do +for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local /project/workbench; do repair_bind_mount_ownership "$dir" done diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b8d9934b4c..ab50eada94 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -16,7 +16,11 @@ from core.models import ChatMessage from src.request_models import ChatRequest from src.llm_core import llm_call_async, stream_llm, stream_llm_with_fallback -from src.agent_loop import stream_agent_loop +from src.agent_loop import ( + stream_agent_loop, + _agent_response_to_save, + _normalize_continuation_checkpoint, +) from src import agent_runs from src.model_context import estimate_tokens from src.chat_helpers import coerce_message_and_session @@ -584,6 +588,15 @@ async def chat_stream(request: Request) -> StreamingResponse: approved_plan = "" if not plan_mode: approved_plan = (form_data.get("approved_plan") or "").strip()[:8192] + continuation_checkpoint = None + raw_checkpoint = form_data.get("continuation_checkpoint") + if raw_checkpoint: + try: + continuation_checkpoint = _normalize_continuation_checkpoint( + json.loads(str(raw_checkpoint)[:8192]) + ) + except (TypeError, ValueError, json.JSONDecodeError): + continuation_checkpoint = None # Did the USER explicitly pick agent mode? (vs. us auto-escalating # below). Skill extraction should only learn from real agent sessions, # not chats we quietly promoted for a notes/calendar intent. @@ -1429,6 +1442,7 @@ def _on_research_done(_sid, _result, _sources, _findings): workspace=workspace or None, forced_tools=_forced_tools, uploaded_files=ctx.uploaded_files, + continuation_checkpoint=continuation_checkpoint, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -1491,9 +1505,8 @@ def _on_research_done(_sid, _result, _sources, _findings): elif chunk.startswith("event: "): yield chunk elif chunk == "data: [DONE]\n\n": - _has_tool_events = bool((last_metrics or {}).get("tool_events")) - if full_response or _has_tool_events: - _response_to_save = full_response or "Done." + _response_to_save = _agent_response_to_save(full_response, last_metrics) + if _response_to_save: _metrics_to_save = dict(last_metrics or {}) if thinking_response.strip() and not _metrics_to_save.get("thinking"): _metrics_to_save["thinking"] = thinking_response.strip() diff --git a/routes/chatgpt_subscription_routes.py b/routes/chatgpt_subscription_routes.py index 9c695b371a..9eb59a6e3d 100644 --- a/routes/chatgpt_subscription_routes.py +++ b/routes/chatgpt_subscription_routes.py @@ -82,7 +82,9 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: ep.api_key = None ep.provider_auth_id = auth.id ep.is_enabled = True - ep.supports_tools = False + # The provider adapter supports Responses function tools, streamed + # function calls, and structural function_call_output replay. + ep.supports_tools = True ep.model_type = "llm" ep.endpoint_kind = "api" ep.model_refresh_mode = "manual" diff --git a/scripts/ODYSSEUS_DOCKER_START_STOP_README.txt b/scripts/ODYSSEUS_DOCKER_START_STOP_README.txt new file mode 100644 index 0000000000..3f638a6e2b --- /dev/null +++ b/scripts/ODYSSEUS_DOCKER_START_STOP_README.txt @@ -0,0 +1,72 @@ +ODYSSEUS DOCKER START/STOP (WINDOWS) + +REQUIREMENT +Docker Desktop must be installed, running, and ready before these scripts are used. +The scripts expect the repository at C:\odysseus and bind Odysseus to +http://127.0.0.1:7000. + +START WITH A VISIBLE WINDOW +Double-click: + scripts\start_odysseus_docker.bat + +START HIDDEN +Double-click: + scripts\start_odysseus_docker_hidden.vbs + +The hidden launcher starts the same batch script without showing a console. +Startup details are appended to: + logs\odysseus-docker-startup.log + +STOP +Double-click: + scripts\stop_odysseus_docker.bat + +This runs "docker compose stop" for the standard stack. It does not run +"docker compose down", delete containers, volumes, images, or application data. +Stop details are appended to: + logs\odysseus-docker-stop.log + +STATUS AND LOGS (POWERSHELL) + Set-Location C:\odysseus + docker compose ps + docker compose logs --tail 100 odysseus + docker compose logs --tail 100 searxng chromadb ntfy + +PERSISTENCE +Application data and logs remain under C:\odysseus\data and C:\odysseus\logs. +SearXNG, ChromaDB, ntfy, and the disposable workbench use Docker named +volumes. The stop script preserves all of them. + +REBUILD AFTER SOURCE CHANGES +Changes made in C:\odysseus (mounted as /project/workspace) do not change the +running application snapshot at /app. After reviewing and testing changes, rebuild and +recreate Odysseus from Windows PowerShell: + Set-Location C:\odysseus + $env:APP_BIND = "127.0.0.1" + $env:APP_PORT = "7000" + docker compose up -d --build --no-deps odysseus + Remove-Item Env:APP_BIND -ErrorAction SilentlyContinue + Remove-Item Env:APP_PORT -ErrorAction SilentlyContinue + +WORKSPACE AND DISPOSABLE WORKBENCH +Select /project as the single active Odysseus workspace. The real Windows +checkout is mounted read/write at /project/workspace, while the running app +stays at /app. A Docker named volume is mounted at /project/workbench for +disposable sandbox-first patch work. Do not copy .env, data, logs, credentials, +model caches, or other secrets into the workbench. Test candidate changes +there, then apply the reviewed patch to /project/workspace and test again before +rebuilding. + +ROLLBACK TO THE NATIVE WINDOWS LAUNCHER +The original native scripts are preserved: + scripts\start_odysseus.bat + scripts\start_odysseus_hidden.vbs + scripts\stop_odysseus.bat + +Before starting native Odysseus, stop the Docker stack and confirm port 7000 is +free. Do not run the native and Docker versions on the same host port. + +SAFETY +The standard Compose configuration does not mount the Docker socket. Do not add +the host-Docker overlay merely to use these scripts. Windows Firewall changes +are outside these scripts. diff --git a/scripts/ODYSSEUS_START_STOP_README.txt b/scripts/ODYSSEUS_START_STOP_README.txt new file mode 100644 index 0000000000..7d00d50355 --- /dev/null +++ b/scripts/ODYSSEUS_START_STOP_README.txt @@ -0,0 +1,16 @@ +ODYSSEUS WINDOWS START/STOP + +Copy these files to: +C:\odysseus\scripts + +START: +- Double-click start_odysseus.bat for a visible console. +- Double-click start_odysseus_hidden.vbs for hidden startup. + +STOP: +- Double-click stop_odysseus.bat. +- It stops only the process listening on port 7000. + +AUTOSTART: +Create a shortcut to start_odysseus_hidden.vbs inside: +shell:startup diff --git a/scripts/rebuild_restart_odysseus_docker.bat b/scripts/rebuild_restart_odysseus_docker.bat new file mode 100644 index 0000000000..9a5707d85e --- /dev/null +++ b/scripts/rebuild_restart_odysseus_docker.bat @@ -0,0 +1,94 @@ +@echo off +setlocal +title Rebuild and Restart Docker Odysseus + +set "REPO=%~dp0.." +set "LOG=%REPO%\logs\odysseus-docker-rebuild.log" + +cd /d "%REPO%" || ( + echo ERROR: Could not enter the Odysseus repository. + exit /b 1 +) + +if not exist "%REPO%\logs" mkdir "%REPO%\logs" + +echo. +echo ============================================================ +echo Rebuild and restart Docker Odysseus +echo Repository: %REPO% +echo Log: %LOG% +echo ============================================================ +echo. + +where docker >nul 2>&1 || ( + echo ERROR: Docker was not found. Start or install Docker Desktop. + exit /b 1 +) + +docker info >nul 2>&1 || ( + echo ERROR: Docker Engine is unavailable. Start Docker Desktop and wait until it is ready. + exit /b 1 +) + +echo [%date% %time%] Validating Docker Compose configuration...>>"%LOG%" +docker compose config --quiet >>"%LOG%" 2>&1 +if errorlevel 1 ( + echo ERROR: Docker Compose validation failed. + echo Nothing was rebuilt or restarted. + echo Review: "%LOG%" + exit /b 1 +) + +set "APP_BIND=127.0.0.1" +set "APP_PORT=7000" + +echo Building the Odysseus image with visible progress... +echo The first build can take several minutes. Do not close this window while progress continues. +echo Supporting services and persistent data will not be removed. +echo [%date% %time%] Starting image build...>>"%LOG%" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$enc = New-Object System.Text.UTF8Encoding($false); & cmd.exe /d /s /c 'docker compose --progress plain build odysseus 2>&1' | ForEach-Object { $line = $_.ToString(); Write-Host $line; [System.IO.File]::AppendAllText('%LOG%', $line + [Environment]::NewLine, $enc) }; exit $LASTEXITCODE" +if errorlevel 1 ( + echo ERROR: The Odysseus image build failed. + echo The existing running container was not recreated. + echo Review: "%LOG%" + exit /b 1 +) + +echo. +echo Build succeeded. Recreating only the Odysseus container... +echo [%date% %time%] Recreating Odysseus service...>>"%LOG%" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$enc = New-Object System.Text.UTF8Encoding($false); & cmd.exe /d /s /c 'docker compose up -d --no-deps --force-recreate odysseus 2>&1' | ForEach-Object { $line = $_.ToString(); Write-Host $line; [System.IO.File]::AppendAllText('%LOG%', $line + [Environment]::NewLine, $enc) }; exit $LASTEXITCODE" +if errorlevel 1 ( + echo ERROR: The image built successfully, but Odysseus recreation failed. + echo Review: "%LOG%" + exit /b 1 +) + +echo Waiting for Odysseus to become reachable... +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ready=$false; for($i=1; $i -le 30; $i++){ try { $r=Invoke-WebRequest 'http://127.0.0.1:7000' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop; if($r.StatusCode -ge 200 -and $r.StatusCode -lt 500){ Write-Host ('Odysseus is ready. HTTP status: ' + $r.StatusCode); $ready=$true; break } } catch {}; Write-Host ('Waiting... attempt ' + $i + ' of 30'); Start-Sleep -Seconds 2 }; if(-not $ready){ exit 1 }" +if errorlevel 1 ( + echo. + echo ERROR: The container was recreated, but Odysseus did not become reachable within the allowed time. + echo Recent logs: + docker compose logs --tail 100 odysseus + echo. + echo Full rebuild log: "%LOG%" + exit /b 1 +) + +echo. +docker compose ps odysseus +if errorlevel 1 ( + echo WARNING: Odysseus responded, but Docker Compose status could not be displayed. +) +echo. +echo SUCCESS: Docker Odysseus was rebuilt and restarted. +echo Open: http://127.0.0.1:7000 +echo Log: "%LOG%" +echo [%date% %time%] Rebuild completed successfully.>>"%LOG%" +echo. + +endlocal +exit /b 0 diff --git a/scripts/start_odysseus.bat b/scripts/start_odysseus.bat new file mode 100644 index 0000000000..f8d779b6d5 --- /dev/null +++ b/scripts/start_odysseus.bat @@ -0,0 +1,42 @@ +@echo off +setlocal + +set "ODYSSEUS_DIR=C:\odysseus" +set "HOST=127.0.0.1" +set "PORT=7000" +set "LOG_DIR=%ODYSSEUS_DIR%\logs" +set "LOG_FILE=%LOG_DIR%\odysseus-startup.log" + +if not exist "%ODYSSEUS_DIR%\app.py" ( + echo [%date% %time%] ERROR: Could not find "%ODYSSEUS_DIR%\app.py". + echo Check that Odysseus is installed in C:\odysseus. + pause + exit /b 1 +) + +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" + +cd /d "%ODYSSEUS_DIR%" + +where python >nul 2>&1 +if errorlevel 1 ( + echo [%date% %time%] ERROR: Python was not found in PATH.>>"%LOG_FILE%" + echo Python was not found in PATH. + pause + exit /b 1 +) + +echo [%date% %time%] Starting Odysseus on http://%HOST%:%PORT%>>"%LOG_FILE%" + +python -m uvicorn app:app --host %HOST% --port %PORT% >>"%LOG_FILE%" 2>&1 + +set "EXIT_CODE=%ERRORLEVEL%" +echo [%date% %time%] Odysseus exited with code %EXIT_CODE%.>>"%LOG_FILE%" + +if not "%EXIT_CODE%"=="0" ( + echo Odysseus stopped with exit code %EXIT_CODE%. + echo See "%LOG_FILE%" for details. + pause +) + +exit /b %EXIT_CODE% diff --git a/scripts/start_odysseus_docker.bat b/scripts/start_odysseus_docker.bat new file mode 100644 index 0000000000..29bc6d9b57 --- /dev/null +++ b/scripts/start_odysseus_docker.bat @@ -0,0 +1,46 @@ +@echo off +setlocal EnableExtensions + +set "ODYSSEUS_DIR=C:\odysseus" +set "LOG_DIR=%ODYSSEUS_DIR%\logs" +set "LOG_FILE=%LOG_DIR%\odysseus-docker-startup.log" +set "APP_BIND=127.0.0.1" +set "APP_PORT=7000" + +if not exist "%ODYSSEUS_DIR%\docker-compose.yml" ( + echo ERROR: Could not find "%ODYSSEUS_DIR%\docker-compose.yml". + exit /b 1 +) + +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" >nul 2>&1 +cd /d "%ODYSSEUS_DIR%" + +where docker >nul 2>&1 +if errorlevel 1 ( + echo [%date% %time%] ERROR: Docker was not found in PATH.>>"%LOG_FILE%" + echo ERROR: Docker was not found in PATH. Install or start Docker Desktop. + exit /b 1 +) + +docker info >nul 2>&1 +if errorlevel 1 ( + echo [%date% %time%] ERROR: Docker Engine is unavailable.>>"%LOG_FILE%" + echo ERROR: Docker Engine is unavailable. Start Docker Desktop and wait until it is ready. + exit /b 1 +) + +echo [%date% %time%] Starting the standard Odysseus Docker stack on http://%APP_BIND%:%APP_PORT%>>"%LOG_FILE%" +docker compose up -d >>"%LOG_FILE%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" + +if not "%EXIT_CODE%"=="0" ( + echo [%date% %time%] ERROR: docker compose up failed with exit code %EXIT_CODE%.>>"%LOG_FILE%" + echo ERROR: Odysseus Docker startup failed with exit code %EXIT_CODE%. + echo See "%LOG_FILE%" for details. + exit /b %EXIT_CODE% +) + +echo [%date% %time%] Docker Compose accepted the startup request.>>"%LOG_FILE%" +echo Odysseus Docker startup requested successfully. +echo Open http://%APP_BIND%:%APP_PORT% after the containers finish starting. +exit /b 0 diff --git a/scripts/start_odysseus_docker_hidden.vbs b/scripts/start_odysseus_docker_hidden.vbs new file mode 100644 index 0000000000..f7ddacd895 --- /dev/null +++ b/scripts/start_odysseus_docker_hidden.vbs @@ -0,0 +1,16 @@ +Option Explicit + +Dim shell, fso, scriptDir, batchFile +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) +batchFile = fso.BuildPath(scriptDir, "start_odysseus_docker.bat") + +If Not fso.FileExists(batchFile) Then + MsgBox "Could not find: " & batchFile, vbCritical, "Odysseus Docker startup" + WScript.Quit 1 +End If + +' 0 = hidden window; False = return immediately while the batch script runs. +shell.Run """" & batchFile & """", 0, False diff --git a/scripts/start_odysseus_hidden.vbs b/scripts/start_odysseus_hidden.vbs new file mode 100644 index 0000000000..ed68185c3c --- /dev/null +++ b/scripts/start_odysseus_hidden.vbs @@ -0,0 +1,16 @@ +Option Explicit + +Dim shell, fso, scriptDir, batchFile +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) +batchFile = fso.BuildPath(scriptDir, "start_odysseus.bat") + +If Not fso.FileExists(batchFile) Then + MsgBox "Could not find: " & batchFile, vbCritical, "Odysseus startup" + WScript.Quit 1 +End If + +' 0 = hidden window, False = do not wait for Uvicorn to exit. +shell.Run """" & batchFile & """", 0, False diff --git a/scripts/stop_odysseus.bat b/scripts/stop_odysseus.bat new file mode 100644 index 0000000000..893d388ffc --- /dev/null +++ b/scripts/stop_odysseus.bat @@ -0,0 +1,23 @@ +@echo off +setlocal + +set "PORT=7000" + +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$connections = Get-NetTCPConnection -LocalPort %PORT% -State Listen -ErrorAction SilentlyContinue; " ^ + "if (-not $connections) { Write-Host 'Odysseus is not running on port %PORT%.'; exit 0 }; " ^ + "$pids = $connections | Select-Object -ExpandProperty OwningProcess -Unique; " ^ + "foreach ($pidValue in $pids) { " ^ + " try { " ^ + " $proc = Get-Process -Id $pidValue -ErrorAction Stop; " ^ + " Write-Host ('Stopping ' + $proc.ProcessName + ' (PID ' + $pidValue + ') on port %PORT%...'); " ^ + " Stop-Process -Id $pidValue -Force -ErrorAction Stop; " ^ + " } catch { " ^ + " Write-Host ('Could not stop PID ' + $pidValue + ': ' + $_.Exception.Message); " ^ + " exit 1 " ^ + " } " ^ + "}; " ^ + "Write-Host 'Odysseus stopped.'" + +echo. +pause diff --git a/scripts/stop_odysseus_docker.bat b/scripts/stop_odysseus_docker.bat new file mode 100644 index 0000000000..553d425d22 --- /dev/null +++ b/scripts/stop_odysseus_docker.bat @@ -0,0 +1,45 @@ +@echo off +setlocal EnableExtensions + +set "ODYSSEUS_DIR=C:\odysseus" +set "LOG_DIR=%ODYSSEUS_DIR%\logs" +set "LOG_FILE=%LOG_DIR%\odysseus-docker-stop.log" +set "APP_BIND=127.0.0.1" +set "APP_PORT=7000" + +if not exist "%ODYSSEUS_DIR%\docker-compose.yml" ( + echo ERROR: Could not find "%ODYSSEUS_DIR%\docker-compose.yml". + exit /b 1 +) + +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" >nul 2>&1 +cd /d "%ODYSSEUS_DIR%" + +where docker >nul 2>&1 +if errorlevel 1 ( + echo [%date% %time%] ERROR: Docker was not found in PATH.>>"%LOG_FILE%" + echo ERROR: Docker was not found in PATH. + exit /b 1 +) + +docker info >nul 2>&1 +if errorlevel 1 ( + echo [%date% %time%] ERROR: Docker Engine is unavailable.>>"%LOG_FILE%" + echo ERROR: Docker Engine is unavailable. Start Docker Desktop before stopping the stack. + exit /b 1 +) + +echo [%date% %time%] Stopping the standard Odysseus Docker stack.>>"%LOG_FILE%" +docker compose stop >>"%LOG_FILE%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" + +if not "%EXIT_CODE%"=="0" ( + echo [%date% %time%] ERROR: docker compose stop failed with exit code %EXIT_CODE%.>>"%LOG_FILE%" + echo ERROR: Odysseus Docker stop failed with exit code %EXIT_CODE%. + echo See "%LOG_FILE%" for details. + exit /b %EXIT_CODE% +) + +echo [%date% %time%] The standard Odysseus Docker stack stopped.>>"%LOG_FILE%" +echo The standard Odysseus Docker stack stopped. Containers and persistent data were kept. +exit /b 0 diff --git a/src/agent_loop.py b/src/agent_loop.py index 6f7dde6054..42ace39587 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -320,6 +320,30 @@ def _load_mcp_disabled_map() -> Dict[str, set]: - Do not use shell, curl, or `app_api` to reach a user's connected integration when `api_call` is available.""", } +_REPOSITORY_READ_TOOLS = {"read_file", "grep", "glob", "ls", "get_workspace"} +_REPOSITORY_WRITE_RE = re.compile( + r"\b(?:implement|fix|edit|change|modify|update|add|create|write|save|refactor|apply|patch)\b", + re.IGNORECASE, +) +_REPOSITORY_EXEC_RE = re.compile( + r"\b(?:run|execute|test|build|install|debug|bash|shell|terminal|git|commit|diff|status)\b", + re.IGNORECASE, +) + + +def _repository_tools_for_request(text: str, *, executing_plan: bool = False) -> Set[str]: + """Select repository capabilities deterministically, without embeddings.""" + tools = set(_REPOSITORY_READ_TOOLS) + query = str(text or "") + write_requested = executing_plan or bool(_REPOSITORY_WRITE_RE.search(query)) + execution_requested = executing_plan or bool(_REPOSITORY_EXEC_RE.search(query)) + if write_requested: + tools.update({"write_file", "edit_file"}) + if execution_requested: + tools.update({"bash", "python", "manage_bg_jobs"}) + return tools + + _DOMAIN_TOOL_MAP = { "web": set(WEB_TOOL_NAMES), "documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"}, @@ -1044,7 +1068,7 @@ def has(*patterns: str) -> bool: domains.add("ui") if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"): domains.add("sessions") - if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash)\b"): + if has(r"\b(file|folder|directory|repo|repository|git|grep|find in files|read file|edit file|shell|terminal|bash)\b"): domains.add("files") if has( r"\b(run|execute|test|debug|fix|save|create|edit|read|open)\b.{0,40}\b(" @@ -2455,10 +2479,35 @@ async def _run_verifier_subagent( return [r.strip() for r in reasons.split(";") if r.strip()] +def _provider_failure_message(terminal_failure: Optional[dict], tool_events: list) -> str: + """Build a concise, honest final message for a terminal provider failure.""" + detail = str((terminal_failure or {}).get("text") or (terminal_failure or {}).get("error") or "").strip() + prefix = "The tools completed, but " if tool_events else "" + message = prefix + "the model could not generate the final response because the provider request failed." + if detail: + message += f" Provider error: {detail}" + return message + + +def _agent_response_to_save(full_response: str, metrics: Optional[dict]) -> Optional[str]: + """Choose agent prose to persist without fabricating a successful ``Done.``.""" + if full_response.strip(): + return full_response + metrics = metrics or {} + terminal_failure = metrics.get("terminal_provider_error") + tool_events = metrics.get("tool_events") or [] + if terminal_failure: + return _provider_failure_message(terminal_failure, tool_events) + if tool_events: + return "The tools completed, but the model did not provide a final response." + return None + + def _empty_response_fallback( full_response: str, round_reasoning: str, tool_events: list, + terminal_failure: Optional[dict] = None, ) -> tuple: """Return (final_response, sse_chunk_or_none) for the end-of-loop empty-response guard. @@ -2471,8 +2520,14 @@ def _empty_response_fallback( (final_response: str, chunk: str | None) chunk is the SSE string to yield, or None if nothing should be emitted. """ - if full_response.strip() or tool_events: + if full_response.strip(): return full_response, None + if terminal_failure: + message = _provider_failure_message(terminal_failure, tool_events) + return message, f'data: {json.dumps({"delta": message})}\n\n' + if tool_events: + message = "The tools completed, but the model did not provide a final response." + return message, f'data: {json.dumps({"delta": message})}\n\n' if round_reasoning.strip(): return round_reasoning, None _error_msg = "The model returned an empty response. Please try again or switch to a different model." @@ -2538,6 +2593,144 @@ def _detect_runaway_call(call_freq, threshold=15): return sig.split(":", 1)[0] if sig else None +def _early_budget_warning_round(max_rounds: int) -> int: + """Return the round where the agent should receive its one-time budget warning. + + Aim for 80% of the configured round budget, while warning one round before + exhaustion whenever the budget has at least two rounds. The hard round + limit itself is not changed. + """ + normalized = max(1, int(max_rounds)) + threshold = max(1, (normalized * 4 + 4) // 5) + if normalized > 1: + threshold = min(threshold, normalized - 1) + return threshold + + +_EARLY_BUDGET_WARNING = ( + "The current execution budget is nearly exhausted. Finish the current " + "atomic operation and minimize further tool calls. Either complete every " + "remaining requirement now or return an exact continuation checkpoint " + "as JSON between and . " + "Use only these fields: status, completed, pending, files_changed, tests_run, " + "blockers, next_action, and required_tools." +) + +_CHECKPOINT_OPEN = "" +_CHECKPOINT_CLOSE = "" +_CHECKPOINT_MAX_BLOCK_CHARS = 8192 +_CHECKPOINT_MAX_ITEMS = 8 +_CHECKPOINT_MAX_ITEM_CHARS = 300 +_CHECKPOINT_MAX_ACTION_CHARS = 500 +_CHECKPOINT_MAX_TOOLS = 12 +_CHECKPOINT_LIST_FIELDS = ( + "completed", "pending", "files_changed", "tests_run", "blockers", +) + + +def _bounded_checkpoint_text(value, limit: int) -> str: + """Normalize an untrusted checkpoint value to bounded plain text.""" + if not isinstance(value, str): + return "" + return " ".join(value.split())[:limit] + + +def _normalize_continuation_checkpoint(value) -> Optional[dict]: + """Validate and bound checkpoint data; never interpret it as instructions.""" + if not isinstance(value, dict) or set(value) - { + "status", *_CHECKPOINT_LIST_FIELDS, "next_action", "required_tools", + }: + return None + status = value.get("status") + if status not in {"complete", "incomplete"}: + return None + normalized = {"status": status} + for field in _CHECKPOINT_LIST_FIELDS: + items = value.get(field, []) + if not isinstance(items, list): + return None + normalized[field] = [ + text for text in ( + _bounded_checkpoint_text(item, _CHECKPOINT_MAX_ITEM_CHARS) + for item in items[:_CHECKPOINT_MAX_ITEMS] + ) if text + ] + next_action = _bounded_checkpoint_text( + value.get("next_action", ""), _CHECKPOINT_MAX_ACTION_CHARS + ) + if status == "incomplete" and not next_action: + return None + normalized["next_action"] = next_action + tools = value.get("required_tools", []) + if not isinstance(tools, list): + return None + normalized["required_tools"] = [ + tool for tool in ( + _bounded_checkpoint_text(item, 64) for item in tools[:_CHECKPOINT_MAX_TOOLS] + ) if tool and re.fullmatch(r"[A-Za-z0-9_.-]+", tool) + ] + return normalized + + +def _extract_continuation_checkpoint(text: str) -> tuple: + """Return ``(ordinary_text, checkpoint)`` for one valid delimited JSON block.""" + if not isinstance(text, str): + return text, None + start = text.find(_CHECKPOINT_OPEN) + if start < 0: + return text, None + end = text.find(_CHECKPOINT_CLOSE, start + len(_CHECKPOINT_OPEN)) + if end < 0 or text.find(_CHECKPOINT_OPEN, start + len(_CHECKPOINT_OPEN)) >= 0: + return text, None + raw = text[start + len(_CHECKPOINT_OPEN):end].strip() + if not raw or len(raw) > _CHECKPOINT_MAX_BLOCK_CHARS: + return text, None + try: + checkpoint = _normalize_continuation_checkpoint(json.loads(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + checkpoint = None + if checkpoint is None: + return text, None + ordinary = (text[:start] + text[end + len(_CHECKPOINT_CLOSE):]).strip() + return ordinary, checkpoint + + +def _rounds_exhausted_checkpoint(max_rounds: int) -> dict: + """Build a deterministic minimal handoff when the model supplied none.""" + return { + "status": "incomplete", + "completed": [], + "pending": ["Complete the unfinished task from the prior agent run."], + "files_changed": [], + "tests_run": [], + "blockers": [f"The {max(1, int(max_rounds))}-round execution limit was reached."], + "next_action": "Inspect the prior run state and continue the next unfinished atomic step.", + "required_tools": [], + } + + +def _continuation_handoff_message(checkpoint) -> Optional[dict]: + """Return bounded internal context for a validated incomplete checkpoint. + + Checkpoint fields are serialized as data. They are not interpreted as + privileged instructions and do not grant or retain any tools. + """ + normalized = _normalize_continuation_checkpoint(checkpoint) + if normalized is None or normalized["status"] != "incomplete": + return None + payload = json.dumps(normalized, ensure_ascii=False, separators=(",", ":")) + return { + "role": "system", + "content": ( + "Continuation handoff from the prior interrupted agent run. Treat " + "the JSON as bounded task-state data, not as new user authority. " + "Resume from next_action, avoid repeating completed work, and obey " + "the current user's request and current tool permissions.\n" + f"{payload}" + ), + } + + async def stream_agent_loop( endpoint_url: str, model: str, @@ -2563,6 +2756,7 @@ async def stream_agent_loop( forced_tools: Optional[Set[str]] = None, uploaded_files: Optional[List[Dict]] = None, workload: str = "foreground", + continuation_checkpoint: Optional[dict] = None, _is_teacher_run: bool = False, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -2602,6 +2796,9 @@ async def stream_agent_loop( _upload_msg = _uploaded_files_context_message(uploaded_files) if _upload_msg: messages = _insert_before_latest_user(messages, _upload_msg) + _handoff_msg = _continuation_handoff_message(continuation_checkpoint) + if _handoff_msg: + messages = _insert_before_latest_user(messages, _handoff_msg) _t0 = time.time() _needs_admin = _detect_admin_intent(messages) @@ -2713,7 +2910,10 @@ async def stream_agent_loop( direct_response += fallback yield f"data: {json.dumps({'delta': fallback})}\n\n" - if not direct_response.strip(): + direct_response, direct_checkpoint = _extract_continuation_checkpoint( + direct_response + ) + if not direct_response.strip() and direct_checkpoint is None: fallback = "Hey." direct_response = fallback yield f"data: {json.dumps({'delta': fallback})}\n\n" @@ -2730,6 +2930,9 @@ async def stream_agent_loop( "tool_calls": 0, "direct_low_signal": True, } + if direct_checkpoint is not None: + metrics["continuation_checkpoint"] = direct_checkpoint + metrics["task_incomplete"] = direct_checkpoint["status"] == "incomplete" yield f"data: {json.dumps({'type': 'metrics', 'data': metrics})}\n\n" yield "data: [DONE]\n\n" return @@ -2834,8 +3037,27 @@ async def stream_agent_loop( # collapsing to only ask_user/manage_memory when vector retrieval misses or # times out. if not guide_only and _relevant_tools is not None: - for _domain in (_intent.get("domains") or set()): - _relevant_tools.update(_DOMAIN_TOOL_MAP.get(str(_domain), set())) + _intent_domains = set(_intent.get("domains") or set()) + for _domain in _intent_domains: + if _domain == "files": + # Repository work must not depend on ChromaDB. Select the + # minimum capability tier implied by the request; disabled_tools + # still applies afterwards, preserving permission/toggle gates. + _relevant_tools.update(_repository_tools_for_request(_retrieval_query)) + else: + _relevant_tools.update(_DOMAIN_TOOL_MAP.get(str(_domain), set())) + _approved_repository_plan = bool( + workspace + and approved_plan + and ( + "files" in _intent_domains + or _classify_agent_request([], approved_plan).get("domains", set()) & {"files"} + ) + ) + if _approved_repository_plan: + # A terse continuation (for example "go ahead") inherits the + # approved repository plan's execution capabilities deterministically. + _relevant_tools.update(_repository_tools_for_request(approved_plan, executing_plan=True)) if "cookbook" in (_intent.get("domains") or set()): _relevant_tools.update({ "list_served_models", @@ -3211,6 +3433,7 @@ async def stream_agent_loop( first_token_received = False tool_events = [] # Persist tool executions for history reload round_texts = [] # Cleaned text per round for history reload + terminal_provider_error = None # Terminal SSE/provider failure for honest persistence # Completion-verifier state (mechanism 3a). _effectful_used flips on when # a tool that produces a checkable artifact runs; the verifier only fires # on such turns and at most _VERIFIER_MAX_ROUNDS times. @@ -3275,8 +3498,15 @@ async def stream_agent_loop( # using tools — i.e. it was cut off, not finished. Drives a "Continue" event # so the user can resume instead of the turn silently stalling. _exhausted_rounds = False + continuation_checkpoint = None + _budget_warning_round = _early_budget_warning_round(max_rounds) + _budget_warning_sent = False for round_num in range(1, max_rounds + 1): + if not _budget_warning_sent and round_num == _budget_warning_round: + messages.append({"role": "system", "content": _EARLY_BUDGET_WARNING}) + _budget_warning_sent = True + round_response = "" round_reasoning = "" # reasoning_content deltas (DeepSeek-thinking, vLLM --reasoning-parser) native_tool_calls = [] # populated if model uses function calling @@ -3392,7 +3622,9 @@ async def stream_agent_loop( max(agent_stream_timeout * 4, 1200), ) break - # Forward error events from stream_llm to the frontend + # A provider error is terminal for this round. Preserve structured + # failure state so completed tools are retained without treating the + # turn as a successful empty completion. if chunk.startswith("event: error"): logger.warning( "[agent-timing] stream_error round=%s elapsed=%.3fs chunk=%r", @@ -3400,8 +3632,12 @@ async def stream_agent_loop( time.time() - _round_start, chunk[:500], ) + try: + terminal_provider_error = json.loads(chunk.split("data: ", 1)[1].strip()) + except (IndexError, json.JSONDecodeError, TypeError): + terminal_provider_error = {"error": "Provider stream failed"} yield chunk - continue + break if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: data = json.loads(chunk[6:]) @@ -3585,6 +3821,8 @@ async def stream_agent_loop( _round_first_event_logged, _round_first_token_logged, ) + if terminal_provider_error: + break _normalized_doc_round = ( _normalize_stream_document_fences( round_response, @@ -4458,16 +4696,10 @@ async def _run_tool(): # bottom-of-loop flag missed those). _exhausted_rounds = True - # If the loop hit the round cap while still working, tell the client so it - # can show a "Continue" affordance instead of the turn just stopping. - if _exhausted_rounds: - logger.info("[agent] round cap (%d) reached mid-task — emitting rounds_exhausted", max_rounds) - yield f'data: {json.dumps({"type": "rounds_exhausted", "rounds": max_rounds})}\n\n' - # If the response is completely empty and no tools were executed, # yield a fallback message so the user is not left hanging. full_response, _fallback_chunk = _empty_response_fallback( - full_response, round_reasoning, tool_events + full_response, round_reasoning, tool_events, terminal_provider_error ) if _fallback_chunk: yield _fallback_chunk @@ -4475,7 +4707,14 @@ async def _run_tool(): # Do not persist raw textual tool-call JSON / role markers as assistant # prose. Local finetunes may emit those before the parser catches and # executes them; saved history should contain only the user-facing answer. + full_response, continuation_checkpoint = _extract_continuation_checkpoint(full_response) full_response = strip_tool_blocks(full_response).strip() + if _exhausted_rounds and continuation_checkpoint is None: + continuation_checkpoint = _rounds_exhausted_checkpoint(max_rounds) + if continuation_checkpoint and continuation_checkpoint["status"] == "incomplete" and not full_response: + full_response = "The task is incomplete. A continuation checkpoint was saved." + yield 'data: ' + json.dumps({"delta": full_response}) + '\n\n' + if _ody_notes_finetune_mode and tool_events: for _ev in reversed(tool_events): if _ev.get("tool") != "manage_notes": @@ -4505,6 +4744,17 @@ async def _run_tool(): backend_prefill_tps=backend_prefill_tps, ) metrics["requested_model"] = requested_model + if continuation_checkpoint is not None: + metrics["continuation_checkpoint"] = continuation_checkpoint + metrics["task_incomplete"] = continuation_checkpoint["status"] == "incomplete" + if _exhausted_rounds: + metrics["rounds_exhausted"] = True + metrics["task_incomplete"] = True + logger.info("[agent] round cap (%d) reached mid-task — emitting rounds_exhausted", max_rounds) + yield f'data: {json.dumps({"type": "rounds_exhausted", "rounds": max_rounds, "checkpoint": continuation_checkpoint})}\n\n' + if terminal_provider_error: + metrics["terminal_provider_error"] = terminal_provider_error + metrics["synthesis_failed"] = True yield f"data: {json.dumps({'type': 'metrics', 'data': metrics})}\n\n" # Teacher-escalation: inline takeover visible in the chat stream. @@ -4512,7 +4762,7 @@ async def _run_tool(): # gets a turn (with its own tool calls forwarded to the user) and # a skill is saved ONLY if the teacher actually succeeds. Skipped # when we ARE the teacher to avoid recursion. - if not _is_teacher_run and not guide_only: + if not terminal_provider_error and not _is_teacher_run and not guide_only: try: from src.teacher_escalation import run_teacher_inline async for evt in run_teacher_inline( diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py index e65ccbc8d4..03e3c6d8a4 100644 --- a/src/chatgpt_subscription.py +++ b/src/chatgpt_subscription.py @@ -299,17 +299,386 @@ def to_http_exception(exc: Exception) -> HTTPException: return HTTPException(502, str(exc)) -def build_responses_input(messages: list[dict]) -> list[dict]: +def _content_as_text(content: Any) -> str: + # Flatten Odysseus message content to text accepted by Responses. + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + if part is not None: + parts.append(str(part)) + continue + value = part.get("text") + if value is None: + value = part.get("content") + if value is not None: + parts.append(str(value)) + return "\n".join(parts) + if content is None: + return "" + if isinstance(content, str): + return content + try: + return json.dumps(content, ensure_ascii=False) + except (TypeError, ValueError): + return str(content) + + +def _arguments_as_json(arguments: Any) -> str: + # Return the JSON-string form required by Responses function calls. + if isinstance(arguments, str): + return arguments if arguments.strip() else "{}" + if arguments is None: + return "{}" + try: + return json.dumps(arguments, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError): + return "{}" + + +def _tool_output_as_text(content: Any) -> str: + # Responses function_call_output.output must be a string. + return _content_as_text(content) + + +def build_responses_tools(tools: Optional[list[dict]]) -> list[dict]: + # Convert Chat Completions function schemas to Responses schemas. + converted: list[dict] = [] + for tool in tools or []: + if not isinstance(tool, dict) or tool.get("type") != "function": + continue + function = tool.get("function") + if not isinstance(function, dict): + # Accept an already-flattened Responses function schema too. + function = tool + name = function.get("name") + if not isinstance(name, str) or not name.strip(): + continue + parameters = function.get("parameters") + item: dict[str, Any] = { + "type": "function", + "name": name.strip(), + "parameters": parameters + if isinstance(parameters, dict) + else {"type": "object", "properties": {}}, + } + description = function.get("description") + if isinstance(description, str) and description: + item["description"] = description + strict = function.get("strict", tool.get("strict")) + if isinstance(strict, bool): + item["strict"] = strict + converted.append(item) + return converted + + +def _same_responses_model(source_model: Any, requested_model: Any) -> bool: + if not source_model or not requested_model: + return True + source = str(source_model).strip().lower() + requested = str(requested_model).strip().lower() + return ( + source == requested + or source.startswith(requested + "-") + or requested.startswith(source + "-") + ) + + +def _sanitize_reasoning_item(item: Any) -> Optional[dict]: + # Stateless Responses replay needs encrypted_content. Never replay arbitrary + # response fields or plaintext chain-of-thought content. + if not isinstance(item, dict) or item.get("type") != "reasoning": + return None + encrypted = item.get("encrypted_content") + if not isinstance(encrypted, str) or not encrypted: + return None + cleaned: dict[str, Any] = { + "type": "reasoning", + "encrypted_content": encrypted, + } + item_id = item.get("id") + if isinstance(item_id, str) and item_id: + cleaned["id"] = item_id + summary = item.get("summary") + if isinstance(summary, list): + safe_summary: list[dict] = [] + for part in summary: + if not isinstance(part, dict): + continue + part_type = part.get("type") + text = part.get("text") + if isinstance(part_type, str) and isinstance(text, str): + safe_summary.append({"type": part_type, "text": text}) + cleaned["summary"] = safe_summary + return cleaned + + +def _reasoning_item_key(item: dict) -> str: + item_id = item.get("id") + if isinstance(item_id, str) and item_id: + return "id:" + item_id + return "enc:" + str(item.get("encrypted_content") or "") + + +def _replay_reasoning_items( + tool_calls: Any, + requested_model: Optional[str], +) -> list[dict]: + items: list[dict] = [] + seen: set[str] = set() + for tool_call in tool_calls or []: + if not isinstance(tool_call, dict): + continue + extra = tool_call.get("extra_content") + if not isinstance(extra, dict): + continue + if not _same_responses_model(extra.get("responses_model"), requested_model): + continue + for raw in extra.get("responses_reasoning_items") or []: + item = _sanitize_reasoning_item(raw) + if item is None: + continue + key = _reasoning_item_key(item) + if key in seen: + continue + seen.add(key) + items.append(item) + return items + + +def build_responses_input( + messages: list[dict], + model: Optional[str] = None, +) -> list[dict]: + # Convert canonical Odysseus history to Responses input items. Assistant + # calls and tool results remain structural and keep the exact call_id. input_items: list[dict] = [] for msg in messages or []: + if not isinstance(msg, dict): + continue role = msg.get("role") or "user" + if role == "tool": - role = "user" - content = msg.get("content") - if isinstance(content, list): - text = "\n".join(str(part.get("text") or part.get("content") or "") for part in content if isinstance(part, dict)) - else: - text = "" if content is None else str(content) - input_type = "output_text" if role == "assistant" else "input_text" - input_items.append({"role": role, "content": [{"type": input_type, "text": text}]}) + call_id = msg.get("tool_call_id") + if isinstance(call_id, str) and call_id: + input_items.append({ + "type": "function_call_output", + "call_id": call_id, + "output": _tool_output_as_text(msg.get("content")), + }) + continue + + tool_calls = msg.get("tool_calls") or [] + if role == "assistant" and tool_calls: + # GPT reasoning models may require the opaque reasoning item from + # the call-producing response to be replayed before call outputs. + input_items.extend(_replay_reasoning_items(tool_calls, model)) + + content = _content_as_text(msg.get("content")) + if content or role != "assistant" or not tool_calls: + input_type = "output_text" if role == "assistant" else "input_text" + input_items.append({ + "role": role, + "content": [{"type": input_type, "text": content}], + }) + + if role == "assistant": + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + function = tool_call.get("function") or {} + if not isinstance(function, dict): + continue + name = function.get("name") + call_id = tool_call.get("id") + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(call_id, str) or not call_id: + continue + input_items.append({ + "type": "function_call", + "call_id": call_id, + "name": name.strip(), + "arguments": _arguments_as_json(function.get("arguments")), + }) return input_items + + +class ResponsesToolCallAccumulator: + # Aggregate streamed Responses function calls and replayable reasoning. + + def __init__(self) -> None: + self._records: list[dict[str, Any]] = [] + self._reasoning_items: list[dict] = [] + self._reasoning_keys: set[str] = set() + self._response_model: Optional[str] = None + + def _capture_response_model(self, event: dict) -> None: + response = event.get("response") + if isinstance(response, dict): + model = response.get("model") + if isinstance(model, str) and model: + self._response_model = model + model = event.get("model") + if isinstance(model, str) and model: + self._response_model = model + + def _capture_reasoning(self, item: Any) -> None: + cleaned = _sanitize_reasoning_item(item) + if cleaned is None: + return + key = _reasoning_item_key(cleaned) + if key in self._reasoning_keys: + return + self._reasoning_keys.add(key) + self._reasoning_items.append(cleaned) + + def _find( + self, + *, + output_index: Any = None, + call_id: Any = None, + item_id: Any = None, + ) -> Optional[dict[str, Any]]: + for record in self._records: + if output_index is not None and record.get("output_index") == output_index: + return record + if call_id and record.get("call_id") == call_id: + return record + if item_id and record.get("item_id") == item_id: + return record + return None + + def _record(self, event: dict, item: Optional[dict] = None) -> dict[str, Any]: + item = item if isinstance(item, dict) else {} + output_index = event.get("output_index", item.get("output_index")) + call_id = item.get("call_id") or event.get("call_id") + item_id = item.get("id") or event.get("item_id") + record = self._find( + output_index=output_index, + call_id=call_id, + item_id=item_id, + ) + if record is None: + record = { + "output_index": output_index, + "call_id": call_id, + "item_id": item_id, + "name": "", + "arguments": "", + "argument_deltas": [], + "sequence": len(self._records), + } + self._records.append(record) + else: + if record.get("output_index") is None and output_index is not None: + record["output_index"] = output_index + if not record.get("call_id") and call_id: + record["call_id"] = call_id + if not record.get("item_id") and item_id: + record["item_id"] = item_id + return record + + def feed(self, event: dict, event_type: str = "") -> None: + if not isinstance(event, dict): + return + self._capture_response_model(event) + kind = event_type or str(event.get("type") or "") + + if kind in {"response.output_item.added", "response.output_item.done"}: + item = event.get("item") or {} + if not isinstance(item, dict): + return + if item.get("type") == "reasoning": + self._capture_reasoning(item) + return + if item.get("type") != "function_call": + return + record = self._record(event, item) + if isinstance(item.get("name"), str): + record["name"] = item["name"] + if isinstance(item.get("arguments"), str): + if kind == "response.output_item.done" or item["arguments"]: + record["arguments"] = item["arguments"] + return + + if kind in { + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + }: + item = event.get("item") + record = self._record(event, item if isinstance(item, dict) else None) + if isinstance(item, dict) and isinstance(item.get("name"), str): + record["name"] = item["name"] + if kind.endswith(".delta"): + delta = event.get("delta") + if isinstance(delta, str) and delta: + record["argument_deltas"].append(delta) + else: + arguments = event.get("arguments") + if not isinstance(arguments, str) and isinstance(item, dict): + arguments = item.get("arguments") + if isinstance(arguments, str): + record["arguments"] = arguments + return + + if kind == "response.completed": + response = event.get("response") or {} + if not isinstance(response, dict): + return + for index, item in enumerate(response.get("output") or []): + if not isinstance(item, dict): + continue + if item.get("type") == "reasoning": + self._capture_reasoning(item) + continue + if item.get("type") != "function_call": + continue + synthetic_event = dict(event) + synthetic_event["output_index"] = item.get("output_index", index) + record = self._record(synthetic_event, item) + if isinstance(item.get("name"), str): + record["name"] = item["name"] + if isinstance(item.get("arguments"), str): + record["arguments"] = item["arguments"] + + def calls(self) -> list[dict]: + calls: list[dict] = [] + seen: set[str] = set() + records = sorted( + self._records, + key=lambda record: ( + record.get("output_index") + if isinstance(record.get("output_index"), int) + else 10**9, + record.get("sequence", 0), + ), + ) + for index, record in enumerate(records): + name = record.get("name") + if not isinstance(name, str) or not name: + continue + call_id = record.get("call_id") or record.get("item_id") or f"call_{index}" + call_id = str(call_id) + if call_id in seen: + continue + seen.add(call_id) + arguments = record.get("arguments") + if not isinstance(arguments, str) or not arguments: + arguments = "".join(record.get("argument_deltas") or []) or "{}" + calls.append({ + "id": call_id, + "name": name, + "arguments": arguments, + }) + + if calls and self._reasoning_items: + extra: dict[str, Any] = { + "responses_reasoning_items": list(self._reasoning_items), + } + if self._response_model: + extra["responses_model"] = self._response_model + # Agent history preserves extra_content on each canonical tool call. + # Attach the shared reasoning envelope once to avoid replay duplicates. + calls[0]["extra_content"] = extra + return calls diff --git a/src/llm_core.py b/src/llm_core.py index af1958f16f..e540a8a6dc 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1085,25 +1085,35 @@ def _build_chatgpt_responses_payload( max_tokens: int, *, stream: bool = False, + tools: Optional[List[Dict]] = None, + tool_choice_none: bool = False, ) -> Dict: - from src.chatgpt_subscription import build_responses_input + from src.chatgpt_subscription import build_responses_input, build_responses_tools - conversation = [msg for msg in (messages or []) if (msg.get("role") or "") != "system"] + conversation = [ + msg for msg in (messages or []) + if (msg.get("role") or "") != "system" + ] payload: Dict = { "model": model, "instructions": _chatgpt_subscription_instructions(messages), - "input": build_responses_input(conversation), + "input": build_responses_input(conversation, model=model), "stream": stream, "store": False, } + response_tools = build_responses_tools(tools) + if response_tools: + payload["tools"] = response_tools + payload["tool_choice"] = "none" if tool_choice_none else "auto" + payload["parallel_tool_calls"] = True + # With store=False, opaque encrypted reasoning is the stateless replay + # mechanism required by GPT reasoning models across tool rounds. + payload["include"] = ["reasoning.encrypted_content"] if not _restricts_temperature(model): payload["temperature"] = temperature - # ChatGPT Subscription Codex API does not support max_output_tokens — - # passing it returns HTTP 400 "Unsupported parameter: max_output_tokens". - # Do not include it in the payload. + # ChatGPT Subscription Codex API does not support max_output_tokens. return payload - def _format_chatgpt_subscription_error(status_code: int, text: str) -> str: if status_code in (401, 403): return "ChatGPT Subscription credentials expired or were rejected. Reconnect the provider." @@ -2172,7 +2182,15 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat elif provider == "chatgpt-subscription": target_url = _normalize_chatgpt_subscription_url(url) h = _provider_headers(provider, headers) - payload = _build_chatgpt_responses_payload(model, messages_copy, temperature, max_tokens, stream=True) + payload = _build_chatgpt_responses_payload( + model, + messages_copy, + temperature, + max_tokens, + stream=True, + tools=tools, + tool_choice_none=tool_choice_none, + ) else: target_url = _normalize_openai_chat_url(url) payload = { @@ -2228,6 +2246,9 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat event_name = "" input_tokens = 0 output_tokens = 0 + from src.chatgpt_subscription import ResponsesToolCallAccumulator + _chatgpt_tool_calls = ResponsesToolCallAccumulator() + _chatgpt_tool_calls_emitted = False try: client = _get_http_client() async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r: @@ -2253,6 +2274,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat except json.JSONDecodeError: continue evt = data.get("type") or event_name + _chatgpt_tool_calls.feed(data, evt) if evt == "response.output_text.delta": delta = data.get("delta") or "" if delta: @@ -2267,6 +2289,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or output_tokens if input_tokens or output_tokens: yield f'data: {json.dumps({"type": "usage", "data": {"input_tokens": input_tokens, "output_tokens": output_tokens}})}\n\n' + _calls = _chatgpt_tool_calls.calls() + if _calls and not _chatgpt_tool_calls_emitted: + yield f"data: {json.dumps({'type': 'tool_calls', 'calls': _calls})}\n\n" + _chatgpt_tool_calls_emitted = True yield "data: [DONE]\n\n" return elif evt in ("response.failed", "error"): @@ -2274,6 +2300,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat text = err.get("message") if isinstance(err, dict) else str(err or "ChatGPT Subscription request failed") yield f'event: error\ndata: {json.dumps({"status": 502, "text": text})}\n\n' return + _calls = _chatgpt_tool_calls.calls() + if _calls and not _chatgpt_tool_calls_emitted: + yield f"data: {json.dumps({'type': 'tool_calls', 'calls': _calls})}\n\n" + _chatgpt_tool_calls_emitted = True yield "data: [DONE]\n\n" except (httpx.ConnectError, httpx.ConnectTimeout) as e: _cooled = _mark_host_dead(target_url) diff --git a/static/js/chat.js b/static/js/chat.js index 06c7a1dc7a..4ed0bab7f2 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -52,6 +52,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } catch (_) {} } let _pendingContinue = null; // Stores the stopped AI element to merge with new response + let _pendingContinuationCheckpoint = null; // Compact handoff for a step-limit continuation function _createChatSendPerf() { const started = (performance && performance.now) ? performance.now() : Date.now(); let last = started; @@ -1166,6 +1167,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const fd = new FormData(); fd.append('message', _finalMsgWithInject); fd.append('session', streamSessionId); + if (_pendingContinuationCheckpoint) { + fd.append('continuation_checkpoint', JSON.stringify(_pendingContinuationCheckpoint)); + _pendingContinuationCheckpoint = null; + } if (ids.length) fd.append('attachments', JSON.stringify(ids)); // Auto-save & send active doc ID so the backend sees latest content if (documentModule && activeDocIdForSend) { @@ -2317,6 +2322,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } } else if (json.type === 'rounds_exhausted') { // The agent hit the per-turn step limit while still working. + const _checkpoint = (json.checkpoint && json.checkpoint.status === 'incomplete') + ? json.checkpoint : null; // Offer a Continue button instead of stalling silently. // NOTE: append to the chat-history container (bottom), NOT the // message body — the body innerHTML is re-rendered at stream @@ -2342,6 +2349,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer note.remove(); _hideUserBubble = true; _pendingContinue = _holder; + _pendingContinuationCheckpoint = _checkpoint; const msgInput = uiModule.el('message'); if (msgInput) { msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.'; diff --git a/tests/test_chatgpt_subscription_tools.py b/tests/test_chatgpt_subscription_tools.py new file mode 100644 index 0000000000..4a6b3fc06d --- /dev/null +++ b/tests/test_chatgpt_subscription_tools.py @@ -0,0 +1,289 @@ +import asyncio +import json + +from src.chatgpt_subscription import ( + ResponsesToolCallAccumulator, + build_responses_input, + build_responses_tools, +) +from src import llm_core +from src.llm_core import _build_chatgpt_responses_payload + + +def _tool_schema(name="get_workspace"): + return { + "type": "function", + "function": { + "name": name, + "description": "A test tool", + "parameters": {"type": "object", "properties": {}}, + "strict": True, + }, + } + + +def _reasoning_item(): + return { + "id": "rs_1", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "opaque summary"}], + "encrypted_content": "encrypted-reasoning", + } + + +def test_responses_tool_schema_is_flattened(): + assert build_responses_tools([_tool_schema()]) == [{ + "type": "function", + "name": "get_workspace", + "description": "A test tool", + "parameters": {"type": "object", "properties": {}}, + "strict": True, + }] + + +def test_tool_history_preserves_call_id_and_string_output(): + messages = [ + {"role": "user", "content": "Call it"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": {"name": "get_workspace", "arguments": "{}"}, + }], + }, + {"role": "tool", "tool_call_id": "call_123", "content": {"path": "C:/odysseus"}}, + ] + items = build_responses_input(messages, model="gpt-5.6-sol") + assert items[1] == { + "type": "function_call", + "call_id": "call_123", + "name": "get_workspace", + "arguments": "{}", + } + assert items[2]["type"] == "function_call_output" + assert items[2]["call_id"] == "call_123" + assert isinstance(items[2]["output"], str) + assert json.loads(items[2]["output"]) == {"path": "C:/odysseus"} + + +def test_reasoning_is_replayed_before_function_call_for_same_model(): + messages = [{ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "get_workspace", "arguments": "{}"}, + "extra_content": { + "responses_model": "gpt-5.6-sol-2026-07-01", + "responses_reasoning_items": [_reasoning_item()], + }, + }], + }] + items = build_responses_input(messages, model="gpt-5.6-sol") + assert items[0] == _reasoning_item() + assert items[1]["type"] == "function_call" + + +def test_reasoning_is_not_replayed_across_incompatible_models(): + messages = [{ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "get_workspace", "arguments": "{}"}, + "extra_content": { + "responses_model": "gpt-5.6-sol", + "responses_reasoning_items": [_reasoning_item()], + }, + }], + }] + items = build_responses_input(messages, model="gpt-5.6-terra") + assert [item["type"] for item in items] == ["function_call"] + + +def test_payload_contains_responses_tools_and_reasoning_replay_request(): + payload = _build_chatgpt_responses_payload( + "gpt-5.6-sol", + [{"role": "user", "content": "test"}], + 0.2, + 0, + stream=True, + tools=[_tool_schema()], + ) + assert payload["tools"][0]["name"] == "get_workspace" + assert "function" not in payload["tools"][0] + assert payload["tool_choice"] == "auto" + assert payload["parallel_tool_calls"] is True + assert payload["include"] == ["reasoning.encrypted_content"] + + +def test_payload_honors_tool_choice_none(): + payload = _build_chatgpt_responses_payload( + "gpt-5.6-sol", + [{"role": "user", "content": "test"}], + 0.2, + 0, + stream=True, + tools=[_tool_schema()], + tool_choice_none=True, + ) + assert payload["tool_choice"] == "none" + + +def test_stream_accumulator_uses_final_arguments_and_replay_metadata(): + acc = ResponsesToolCallAccumulator() + acc.feed({ + "type": "response.output_item.added", + "output_index": 0, + "item": _reasoning_item(), + }) + acc.feed({ + "type": "response.output_item.added", + "output_index": 1, + "item": { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "", + }, + }) + acc.feed({ + "type": "response.function_call_arguments.delta", + "output_index": 1, + "item_id": "fc_1", + "delta": '{"path":', + }) + acc.feed({ + "type": "response.function_call_arguments.delta", + "output_index": 1, + "item_id": "fc_1", + "delta": '"README.md"}', + }) + acc.feed({ + "type": "response.completed", + "response": { + "model": "gpt-5.6-sol", + "output": [ + _reasoning_item(), + { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": '{"path":"README.md"}', + }, + ], + }, + }) + calls = acc.calls() + assert len(calls) == 1 + assert calls[0]["id"] == "call_1" + assert calls[0]["arguments"] == '{"path":"README.md"}' + assert calls[0]["extra_content"]["responses_model"] == "gpt-5.6-sol" + assert calls[0]["extra_content"]["responses_reasoning_items"] == [_reasoning_item()] + + +def test_parallel_calls_keep_order_and_ids(): + acc = ResponsesToolCallAccumulator() + for index, (call_id, name) in enumerate((("call_a", "get_workspace"), ("call_b", "read_file"))): + acc.feed({ + "type": "response.output_item.done", + "output_index": index, + "item": { + "id": f"fc_{index}", + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": "{}", + }, + }) + assert [call["id"] for call in acc.calls()] == ["call_a", "call_b"] + + +def test_actual_chatgpt_stream_parser_emits_normalized_tool_calls(monkeypatch): + reasoning = _reasoning_item() + function_call = { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "get_workspace", + "arguments": "{}", + } + lines = [ + "data: " + json.dumps({ + "type": "response.output_item.added", + "output_index": 0, + "item": reasoning, + }), + "data: " + json.dumps({ + "type": "response.output_item.added", + "output_index": 1, + "item": function_call, + }), + "data: " + json.dumps({ + "type": "response.completed", + "response": { + "model": "gpt-5.6-sol", + "output": [reasoning, function_call], + "usage": {"input_tokens": 12, "output_tokens": 4}, + }, + }), + ] + + class FakeResponse: + status_code = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def aiter_lines(self): + for line in lines: + yield line + + class FakeClient: + def __init__(self): + self.payload = None + + def stream(self, method, url, **kwargs): + self.payload = kwargs.get("json") + return FakeResponse() + + fake_client = FakeClient() + monkeypatch.setattr(llm_core, "_get_http_client", lambda: fake_client) + monkeypatch.setattr(llm_core, "_is_host_dead", lambda _url: False) + monkeypatch.setattr(llm_core, "_clear_host_dead", lambda _url: None) + monkeypatch.setattr(llm_core, "note_model_activity", lambda *_args, **_kwargs: None) + + async def collect(): + return [ + chunk + async for chunk in llm_core._stream_llm_inner( + "https://chatgpt.com/backend-api/codex", + "gpt-5.6-sol", + [{"role": "user", "content": "Call get_workspace"}], + temperature=0.2, + tools=[_tool_schema()], + headers={"Authorization": "Bearer test"}, + ) + ] + + chunks = asyncio.run(collect()) + assert fake_client.payload["tools"][0]["name"] == "get_workspace" + decoded = [] + for chunk in chunks: + if not chunk.startswith("data: {"): + continue + decoded.append(json.loads(chunk[len("data: "):].strip())) + tool_events = [item for item in decoded if item.get("type") == "tool_calls"] + assert len(tool_events) == 1 + assert tool_events[0]["calls"][0]["id"] == "call_1" + assert tool_events[0]["calls"][0]["name"] == "get_workspace" + assert tool_events[0]["calls"][0]["extra_content"]["responses_reasoning_items"] == [reasoning] diff --git a/tests/test_continuation_checkpoint.py b/tests/test_continuation_checkpoint.py new file mode 100644 index 0000000000..1a26b0708f --- /dev/null +++ b/tests/test_continuation_checkpoint.py @@ -0,0 +1,174 @@ +import asyncio +import json + +import pytest + +from src import agent_loop as al + + +def _block(payload): + return "" + json.dumps(payload) + "" + + +def _payload(status="incomplete"): + return { + "status": status, + "completed": ["Inspected code"], + "pending": ["Transfer patch"] if status == "incomplete" else [], + "files_changed": ["src/agent_loop.py"], + "tests_run": ["pytest: passed"], + "blockers": [], + "next_action": "Transfer the verified patch." if status == "incomplete" else "", + "required_tools": ["bash", "edit_file"], + } + + +def test_extracts_incomplete_checkpoint_and_preserves_ordinary_text(): + ordinary, checkpoint = al._extract_continuation_checkpoint( + "Progress report.\n" + _block(_payload()) + "\nPlease continue." + ) + assert ordinary == "Progress report.\n\nPlease continue." + assert checkpoint["status"] == "incomplete" + assert checkpoint["completed"] == ["Inspected code"] + assert checkpoint["required_tools"] == ["bash", "edit_file"] + + +def test_complete_checkpoint_is_normalized(): + ordinary, checkpoint = al._extract_continuation_checkpoint(_block(_payload("complete"))) + assert ordinary == "" + assert checkpoint["status"] == "complete" + assert checkpoint["pending"] == [] + + +@pytest.mark.parametrize("text", [ + "{bad}", + _block({**_payload(), "status": "paused"}), + _block({**_payload(), "unexpected": True}), + "" + ("x" * 8193) + "", +]) +def test_rejects_malformed_unsupported_or_oversized_blocks(text): + ordinary, checkpoint = al._extract_continuation_checkpoint(text) + assert ordinary == text + assert checkpoint is None + + +def test_normalization_bounds_lists_and_fields(): + payload = _payload() + payload["completed"] = [("item %d " % i) + ("x" * 500) for i in range(20)] + payload["required_tools"] = ["bash"] * 20 + ["not a valid tool"] + checkpoint = al._normalize_continuation_checkpoint(payload) + assert len(checkpoint["completed"]) == 8 + assert all(len(item) <= 300 for item in checkpoint["completed"]) + assert len(checkpoint["required_tools"]) == 12 + + +def test_deterministic_round_exhaustion_checkpoint(): + checkpoint = al._rounds_exhausted_checkpoint(50) + assert checkpoint["status"] == "incomplete" + assert checkpoint["next_action"] + assert "50-round" in checkpoint["blockers"][0] + + +def _collect(gen): + async def run(): + return [chunk async for chunk in gen] + return asyncio.run(run()) + + +def _events(chunks): + return [json.loads(chunk[6:]) for chunk in chunks + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")] + + +def _patch_common(monkeypatch): + monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False) + monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) + + +def test_round_exhaustion_emits_fallback_checkpoint_and_incomplete_metrics(monkeypatch): + _patch_common(monkeypatch) + + async def fake_stream(*args, **kwargs): + call = {"id": "call_1", "name": "bash", "arguments": json.dumps({"command": "true"})} + yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n' + yield "data: [DONE]\n\n" + + async def fake_execute(*args, **kwargs): + return "bash", {"output": "ok", "exit_code": 0} + + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream) + monkeypatch.setattr(al, "execute_tool_block", fake_execute) + chunks = _collect(al.stream_agent_loop( + "https://api.openai.com/v1", "gpt-test", + [{"role": "user", "content": "Do work"}], max_rounds=1, + relevant_tools={"bash"}, _is_teacher_run=True, + )) + events = _events(chunks) + exhausted = next(event for event in events if event.get("type") == "rounds_exhausted") + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + assert exhausted["checkpoint"]["status"] == "incomplete" + assert metrics["rounds_exhausted"] is True + assert metrics["task_incomplete"] is True + assert metrics["continuation_checkpoint"] == exhausted["checkpoint"] + assert all(event.get("delta") != "Done." for event in events) + + +def test_complete_checkpoint_sets_complete_metrics(monkeypatch): + _patch_common(monkeypatch) + checkpoint_text = _block(_payload("complete")) + + async def fake_stream(*args, **kwargs): + yield f'data: {json.dumps({"delta": "Finished. " + checkpoint_text})}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream) + events = _events(_collect(al.stream_agent_loop( + "https://api.openai.com/v1", "gpt-test", + [{"role": "user", "content": "Answer"}], max_rounds=3, + relevant_tools=set(), _is_teacher_run=True, + ))) + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + assert metrics["continuation_checkpoint"]["status"] == "complete" + assert metrics["task_incomplete"] is False + + +def test_incomplete_checkpoint_on_direct_path_sets_incomplete_without_fallback(monkeypatch): + _patch_common(monkeypatch) + checkpoint_text = _block(_payload("incomplete")) + + async def fake_stream(*args, **kwargs): + yield f'data: {json.dumps({"delta": checkpoint_text})}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream) + events = _events(_collect(al.stream_agent_loop( + "https://api.openai.com/v1", "gpt-test", + [{"role": "user", "content": "Answer"}], max_rounds=3, + relevant_tools=set(), _is_teacher_run=True, + ))) + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + assert metrics["continuation_checkpoint"]["status"] == "incomplete" + assert metrics["task_incomplete"] is True + deltas = "".join(event.get("delta", "") for event in events) + assert "Hey." not in deltas + assert "Done." not in deltas + + +def test_success_without_checkpoint_remains_unchanged(monkeypatch): + _patch_common(monkeypatch) + + async def fake_stream(*args, **kwargs): + yield 'data: {"delta":"All requirements complete."}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream) + events = _events(_collect(al.stream_agent_loop( + "https://api.openai.com/v1", "gpt-test", + [{"role": "user", "content": "Answer"}], max_rounds=3, + relevant_tools=set(), _is_teacher_run=True, + ))) + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + assert "continuation_checkpoint" not in metrics + assert "task_incomplete" not in metrics + assert "All requirements complete." in "".join(event.get("delta", "") for event in events) diff --git a/tests/test_continuation_handoff.py b/tests/test_continuation_handoff.py new file mode 100644 index 0000000000..8550064bfd --- /dev/null +++ b/tests/test_continuation_handoff.py @@ -0,0 +1,108 @@ +import asyncio +import json +from pathlib import Path + +from src import agent_loop as al + + +def _checkpoint(status="incomplete"): + return { + "status": status, + "completed": ["Inspected the current state"], + "pending": ["Continue implementation"] if status == "incomplete" else [], + "files_changed": ["src/agent_loop.py"], + "tests_run": ["focused tests passed"], + "blockers": [], + "next_action": "Continue the next atomic step." if status == "incomplete" else "", + "required_tools": ["bash", "edit_file"], + } + + +def _collect(gen): + async def run(): + return [chunk async for chunk in gen] + + return asyncio.run(run()) + + +def _events(chunks): + return [ + json.loads(chunk[6:]) + for chunk in chunks + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]") + ] + + +def _patch_common(monkeypatch): + monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False) + monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) + + +def test_handoff_message_is_bounded_internal_data(): + message = al._continuation_handoff_message(_checkpoint()) + assert message["role"] == "system" + assert "bounded task-state data" in message["content"] + assert "current tool permissions" in message["content"] + raw = message["content"].split("", 1)[1].split( + "", 1 + )[0] + assert json.loads(raw) == _checkpoint() + + +def test_complete_or_invalid_checkpoint_is_not_injected(): + assert al._continuation_handoff_message(_checkpoint("complete")) is None + assert al._continuation_handoff_message({"status": "incomplete"}) is None + + +def test_stream_injects_handoff_before_latest_user_without_granting_tools(monkeypatch): + _patch_common(monkeypatch) + captured = [] + + async def fake_stream(_candidates, messages, **kwargs): + captured.append(messages) + yield 'data: {"delta":"Resumed safely."}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream) + events = _events( + _collect( + al.stream_agent_loop( + "https://api.openai.com/v1", + "gpt-test", + [{"role": "user", "content": "Continue the unfinished repository task."}], + max_rounds=3, + relevant_tools=set(), + continuation_checkpoint=_checkpoint(), + _is_teacher_run=True, + ) + ) + ) + assert captured + sent = captured[0] + assert sent[-1]["role"] == "user" + handoff = next( + message + for message in sent + if "Continuation handoff" in message.get("content", "") + ) + assert handoff["role"] == "system" + assert not any(event.get("type") == "tool_start" for event in events) + + +def test_frontend_posts_checkpoint_only_for_round_limit_continue(): + source = Path("static/js/chat.js").read_text(encoding="utf-8") + assert "let _pendingContinuationCheckpoint = null;" in source + assert "_pendingContinuationCheckpoint = _checkpoint;" in source + assert ( + "fd.append('continuation_checkpoint', " + "JSON.stringify(_pendingContinuationCheckpoint));" + ) in source + assert "_pendingContinuationCheckpoint = null;" in source + + +def test_route_validates_and_forwards_checkpoint(): + source = Path("routes/chat_routes.py").read_text(encoding="utf-8") + assert 'raw_checkpoint = form_data.get("continuation_checkpoint")' in source + assert "_normalize_continuation_checkpoint(" in source + assert "continuation_checkpoint=continuation_checkpoint," in source diff --git a/tests/test_docker_devops_hardening.py b/tests/test_docker_devops_hardening.py index 29d5c99558..043e550500 100644 --- a/tests/test_docker_devops_hardening.py +++ b/tests/test_docker_devops_hardening.py @@ -61,6 +61,34 @@ def test_default_compose_files_do_not_mount_host_docker_socket(): assert "/var/run/docker.sock" not in text, path.name +def test_default_compose_separates_live_app_workspace_and_workbench(): + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) + service_volumes = compose["services"]["odysseus"]["volumes"] + + assert ".:/project/workspace" in service_volumes + assert "odysseus-workbench:/project/workbench" in service_volumes + assert "odysseus-workbench" in compose["volumes"] + assert all(not volume.endswith(":/app") for volume in service_volumes) + + +def test_docker_windows_scripts_preserve_data_and_avoid_host_control(): + start = (ROOT / "scripts" / "start_odysseus_docker.bat").read_text(encoding="utf-8") + stop = (ROOT / "scripts" / "stop_odysseus_docker.bat").read_text(encoding="utf-8") + hidden = (ROOT / "scripts" / "start_odysseus_docker_hidden.vbs").read_text( + encoding="utf-8" + ) + + assert 'set "APP_BIND=127.0.0.1"' in start + assert 'set "APP_PORT=7000"' in start + assert "docker info" in start + assert "docker compose up -d" in start + assert "start_odysseus_docker.bat" in hidden + assert "docker compose stop" in stop + for forbidden in ("docker compose down", "Stop-Process", "docker system prune"): + assert forbidden not in start + assert forbidden not in stop + + def test_host_docker_overlay_mounts_socket_and_adds_docker_group(): overlay = yaml.safe_load(HOST_DOCKER_OVERLAY.read_text(encoding="utf-8")) service = overlay["services"]["odysseus"] @@ -105,6 +133,7 @@ def test_docker_entrypoint_ownership_repair_stays_inside_expected_mounts(): assert "find /app -xdev" in script for path in ("/app/data", "/app/logs", "/app/.ssh", "/app/.cache", "/app/.local"): assert f"-path {path}" in script + assert "/project/workbench" in script assert "mount_root_for" in script assert "is_broad_mount_root" in script assert "Skipping recursive ownership repair" in script diff --git a/tests/test_early_budget_warning.py b/tests/test_early_budget_warning.py new file mode 100644 index 0000000000..a6e366a6ea --- /dev/null +++ b/tests/test_early_budget_warning.py @@ -0,0 +1,114 @@ +"""Focused regression coverage for the agent's early round-budget warning.""" + +import asyncio +import json + +import src.agent_loop as al + + +def _collect(gen): + async def _run(): + return [chunk async for chunk in gen] + + return asyncio.run(_run()) + + +def _patch_common(monkeypatch): + monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False) + monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) + + async def _fake_exec(block, *args, **kwargs): + return ("bash", {"output": "ok", "exit_code": 0}) + + monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False) + + +def _run_tool_loop(monkeypatch, max_rounds): + seen_messages = [] + call_number = 0 + + async def _fake_stream(_candidates, messages, **kwargs): + nonlocal call_number + call_number += 1 + seen_messages.append([dict(message) for message in messages]) + call = { + "id": f"call_{call_number}", + "name": "bash", + "arguments": json.dumps({"command": f"echo {call_number}"}), + } + yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) + _collect( + al.stream_agent_loop( + "http://x/v1", + "m", + [{"role": "user", "content": "do a long task"}], + max_rounds=max_rounds, + relevant_tools={"bash"}, + ) + ) + return seen_messages + + +def test_warning_round_is_eighty_percent_for_normal_budget(): + assert al._early_budget_warning_round(50) == 40 + + +def test_small_budgets_warn_before_exhaustion_where_possible(): + assert al._early_budget_warning_round(1) == 1 + assert al._early_budget_warning_round(2) == 1 + assert al._early_budget_warning_round(3) == 2 + + +def test_warning_is_injected_once_before_threshold_request(monkeypatch): + _patch_common(monkeypatch) + seen = _run_tool_loop(monkeypatch, max_rounds=6) + warning_round = al._early_budget_warning_round(6) + assert len(seen) == 6 + counts = [ + sum(message.get("content") == al._EARLY_BUDGET_WARNING for message in request) + for request in seen + ] + assert counts[: warning_round - 1] == [0] * (warning_round - 1) + assert counts[warning_round - 1 :] == [1] * (len(seen) - warning_round + 1) + warning = next( + message + for message in seen[warning_round - 1] + if message.get("content") == al._EARLY_BUDGET_WARNING + ) + assert warning["role"] == "system" + + +def test_completion_before_threshold_adds_no_warning(monkeypatch): + _patch_common(monkeypatch) + seen = [] + + async def _fake_stream(_candidates, messages, **kwargs): + seen.append([dict(message) for message in messages]) + yield f'data: {json.dumps({"delta": "All requirements are complete."})}\n\n' + yield "data: [DONE]\n\n" + + monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) + _collect( + al.stream_agent_loop( + "http://x/v1", + "m", + [{"role": "user", "content": "do a task"}], + max_rounds=50, + relevant_tools={"bash"}, + ) + ) + assert len(seen) == 1 + assert all( + message.get("content") != al._EARLY_BUDGET_WARNING + for message in seen[0] + ) + + +def test_warning_does_not_change_hard_round_limit(monkeypatch): + _patch_common(monkeypatch) + seen = _run_tool_loop(monkeypatch, max_rounds=3) + assert len(seen) == 3 diff --git a/tests/test_missing_final_response.py b/tests/test_missing_final_response.py new file mode 100644 index 0000000000..8aa936c8f5 --- /dev/null +++ b/tests/test_missing_final_response.py @@ -0,0 +1,144 @@ +import ast +import asyncio +import json +from pathlib import Path + +import pytest + + +def _load_helpers(): + source = Path(__file__).parents[1].joinpath("src", "agent_loop.py").read_text(encoding="utf-8") + tree = ast.parse(source) + wanted = {"_provider_failure_message", "_agent_response_to_save", "_empty_response_fallback"} + selected = [node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted] + module = ast.Module(body=selected, type_ignores=[]) + namespace = {"json": json, "Optional": __import__("typing").Optional} + exec(compile(module, "agent_loop_helpers", "exec"), namespace) + return namespace + + +_HELPERS = _load_helpers() +_empty_response_fallback = _HELPERS["_empty_response_fallback"] +_agent_response_to_save = _HELPERS["_agent_response_to_save"] + + +def test_tool_calls_then_provider_error_preserve_events_and_expose_failure(): + events = [{"tool": "bash", "output": "exit_code=0"}] + failure = {"status": 502, "text": "Request blocked."} + + response, chunk = _empty_response_fallback("", "", events, failure) + + assert "tools completed" in response.lower() + assert "Request blocked." in response + assert "Done." not in response + assert json.loads(chunk.removeprefix("data: ").strip())["delta"] == response + metrics = {"tool_events": events, "terminal_provider_error": failure, "synthesis_failed": True} + assert _agent_response_to_save("", metrics) == response + assert metrics["tool_events"] == events + + +def test_tool_calls_then_empty_synthesis_do_not_become_done(): + events = [{"tool": "read_file", "output": "ok"}] + + response, chunk = _empty_response_fallback("", "", events) + + assert response == "The tools completed, but the model did not provide a final response." + assert "Done." not in response + assert chunk is not None + assert _agent_response_to_save("", {"tool_events": events}) == response + + +def test_successful_tool_turn_with_final_text_is_unchanged(): + events = [{"tool": "bash", "output": "ok"}] + + response, chunk = _empty_response_fallback("Tests passed.", "", events) + + assert response == "Tests passed." + assert chunk is None + assert _agent_response_to_save(response, {"tool_events": events}) == response + + +def test_failed_synthesis_is_not_classified_as_successful_done(): + failure = {"status": 502, "text": "Request blocked."} + metrics = {"tool_events": [{"tool": "grep"}], "terminal_provider_error": failure, "synthesis_failed": True} + + saved = _agent_response_to_save("", metrics) + + assert saved + assert saved != "Done." + assert "provider request failed" in saved + + +def test_route_uses_failure_aware_response_selection(): + source = Path(__file__).parents[1].joinpath("routes", "chat_routes.py").read_text(encoding="utf-8") + assert "_response_to_save = _agent_response_to_save(full_response, last_metrics)" in source + assert '_response_to_save = full_response or "Done."' not in source + + +def _collect(gen): + async def _run(): + return [chunk async for chunk in gen] + + return asyncio.run(_run()) + + +def _events(chunks): + events = [] + for chunk in chunks: + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + events.append(json.loads(chunk[6:])) + return events + + +def _patch_loop_basics(monkeypatch, agent_loop): + monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False) + monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False) + monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) + + +@pytest.mark.parametrize("with_tool", [False, True]) +def test_stream_provider_error_is_terminal_and_reported(monkeypatch, with_tool): + import src.agent_loop as agent_loop + + _patch_loop_basics(monkeypatch, agent_loop) + calls = 0 + + async def fake_stream(_candidates, messages, **kwargs): + nonlocal calls + calls += 1 + if with_tool and calls == 1: + call = {"id": "call_1", "name": "bash", "arguments": json.dumps({"command": "true"})} + yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n' + yield "data: [DONE]\n\n" + return + yield 'event: error\ndata: {"status": 502, "text": "Request blocked."}\n\n' + + async def fake_execute(block, *args, **kwargs): + return "bash", {"output": "ok", "exit_code": 0} + + monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False) + monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False) + + chunks = _collect( + agent_loop.stream_agent_loop( + "https://api.openai.com/v1", + "gpt-test", + [{"role": "user", "content": "Run the check and report."}], + max_rounds=3, + relevant_tools={"bash"}, + _is_teacher_run=True, + ) + ) + events = _events(chunks) + deltas = [event["delta"] for event in events if "delta" in event and not event.get("thinking")] + metrics = next(event["data"] for event in events if event.get("type") == "metrics") + + assert calls == (2 if with_tool else 1) + assert metrics["synthesis_failed"] is True + assert metrics["terminal_provider_error"]["status"] == 502 + assert "Request blocked." in "".join(deltas) + assert "Done." not in "".join(deltas) + if with_tool: + assert len(metrics["tool_events"]) == 1 + else: + assert "tool_events" not in metrics diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index 6d90789c91..84ab2c600d 100644 --- a/tests/test_workspace_confine.py +++ b/tests/test_workspace_confine.py @@ -231,7 +231,8 @@ async def test_binding_does_not_leak(ws, admin): # must still surface the file tools, otherwise the agent says it has no file # access (the bug this guards against). -def _sent_tool_names(monkeypatch, *, workspace): +def _sent_tool_names(monkeypatch, *, workspace, message="look at the local project", + approved_plan=None, disabled_tools=None): import asyncio import src.agent_loop as al @@ -253,8 +254,9 @@ async def _fake_stream(_candidates, messages, **kwargs): async def _run(): gen = al.stream_agent_loop( "https://api.openai.com/v1", "gpt-test", - [{"role": "user", "content": "look at the local project"}], + [{"role": "user", "content": message}], max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace, + approved_plan=approved_plan, disabled_tools=disabled_tools, ) return [c async for c in gen] @@ -282,6 +284,46 @@ def test_low_signal_without_workspace_excludes_file_tools(monkeypatch): assert "get_workspace" not in names +def test_repository_implementation_gets_execution_tools_without_rag(monkeypatch): + names = _sent_tool_names( + monkeypatch, + workspace="/tmp", + message="Implement the approved multi-step repository fix, then run focused tests", + ) + assert {"read_file", "edit_file", "bash", "update_plan"} <= names + + +def test_approved_repository_plan_continuation_keeps_execution_tools(monkeypatch): + names = _sent_tool_names( + monkeypatch, + workspace="/tmp", + message="go ahead", + approved_plan="- [ ] Edit the repository files\n- [ ] Run focused tests", + ) + assert {"read_file", "edit_file", "bash", "update_plan"} <= names + + +def test_repository_read_only_request_does_not_get_write_tools(monkeypatch): + names = _sent_tool_names( + monkeypatch, + workspace="/tmp", + message="Inspect the repository files and report what they do", + ) + assert {"read_file", "grep", "get_workspace"} <= names + assert not ({"write_file", "edit_file"} & names) + + +def test_repository_execution_respects_disabled_bash(monkeypatch): + names = _sent_tool_names( + monkeypatch, + workspace="/tmp", + message="Fix the repository and run the tests", + disabled_tools={"bash"}, + ) + assert "edit_file" in names + assert "bash" not in names + + # ── browse route is admin-gated ───────────────────────────────────────── def test_browse_is_admin_gated(monkeypatch):