Files
hermes-agent/tests/agent/test_tool_batch_segmentation.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

730 lines
30 KiB
Python

"""Segment-aware mixed tool-batch dispatch.
A model response containing several parallel-safe reads plus one unsafe
tool used to lose ALL concurrency: `_should_parallelize_tool_batch` was
all-or-nothing, so one barrier call forced the entire batch onto the
sequential path. `_plan_tool_batch_segments` now splits the batch into
ordered segments — maximal contiguous runs of parallel-safe calls execute
concurrently, barrier calls sequentially — while preserving:
* model tool-result ordering (one result per call, in emission order),
* side-effect boundaries (no call starts before an earlier barrier ends).
"""
import json
import threading
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from run_agent import AIAgent
from agent.tool_dispatch_helpers import (
_plan_tool_batch_segments,
_should_parallelize_tool_batch,
)
from agent.prompt_builder import STEER_MARKER_OPEN
from tools.budget_config import BudgetConfig
from tools.tool_result_storage import PERSISTED_OUTPUT_TAG
def _assert_budget_replaced(content: str) -> None:
"""The oversized result must have been replaced by budget enforcement.
With an active sandbox env (or host-side spillover) the replacement is a
``<persisted-output>`` preview+path block; when persistence is impossible
it falls back to inline truncation. Either way the raw oversized payload
must be gone — that is the behavior these tests pin, not which
replacement shape was used.
"""
assert PERSISTED_OUTPUT_TAG in content or "Truncated:" in content, content[:200]
assert "L" * 1_000 not in content
def _tc(name="web_search", arguments="{}", call_id=None):
return SimpleNamespace(
id=call_id or f"call_{uuid.uuid4().hex[:8]}",
type="function",
function=SimpleNamespace(name=name, arguments=arguments),
)
def _kinds(segments):
return [kind for kind, _ in segments]
def _flatten_ids(segments):
return [tc.id for _, calls in segments for tc in calls]
# ---------------------------------------------------------------------------
# Planner unit tests
# ---------------------------------------------------------------------------
class TestPlanToolBatchSegments:
def test_all_safe_batch_is_single_parallel_segment(self):
calls = [_tc("web_search"), _tc("read_file", '{"path":"a.py"}'), _tc("web_extract")]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["parallel"]
assert _flatten_ids(segments) == [c.id for c in calls]
def test_three_safe_reads_plus_trailing_unsafe_keeps_reads_parallel(self):
"""The headline case: 3 safe reads + 1 unsafe tool must NOT go fully sequential."""
calls = [
_tc("web_search", call_id="r1"),
_tc("web_search", call_id="r2"),
_tc("read_file", '{"path":"a.py"}', call_id="r3"),
_tc("terminal", '{"command":"echo hi"}', call_id="b1"),
]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["parallel", "sequential"]
assert [tc.id for tc in segments[0][1]] == ["r1", "r2", "r3"]
assert [tc.id for tc in segments[1][1]] == ["b1"]
def test_barrier_in_middle_splits_runs_and_preserves_order(self):
calls = [
_tc("web_search", call_id="r1"),
_tc("web_search", call_id="r2"),
_tc("terminal", '{"command":"make"}', call_id="b1"),
_tc("web_search", call_id="r3"),
_tc("web_search", call_id="r4"),
]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["parallel", "sequential", "parallel"]
assert _flatten_ids(segments) == ["r1", "r2", "b1", "r3", "r4"]
def test_single_safe_call_after_barrier_is_demoted_and_merged(self):
# parallel run of 1 gains nothing — demote to sequential and merge
# with the adjacent barrier segment.
calls = [
_tc("web_search", call_id="r1"),
_tc("web_search", call_id="r2"),
_tc("terminal", '{"command":"make"}', call_id="b1"),
_tc("web_search", call_id="r3"),
]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["parallel", "sequential"]
assert [tc.id for tc in segments[1][1]] == ["b1", "r3"]
def test_never_parallel_tool_is_a_barrier(self):
calls = [
_tc("web_search", call_id="r1"),
_tc("web_search", call_id="r2"),
_tc("clarify", '{"question":"?"}', call_id="c1"),
]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["parallel", "sequential"]
assert [tc.id for tc in segments[1][1]] == ["c1"]
def test_overlapping_paths_split_across_segments(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = [
_tc("read_file", '{"path":"a.py"}', call_id="w1"),
_tc("web_search", call_id="r1"),
_tc("write_file", '{"path":"a.py","content":"x"}', call_id="w2"),
_tc("web_search", call_id="r2"),
]
segments = _plan_tool_batch_segments(calls)
# w2 conflicts with w1 → closes the first run; w2+r2 form the second.
assert _kinds(segments) == ["parallel", "parallel"]
assert [tc.id for tc in segments[0][1]] == ["w1", "r1"]
assert [tc.id for tc in segments[1][1]] == ["w2", "r2"]
# Order and completeness preserved.
assert _flatten_ids(segments) == ["w1", "r1", "w2", "r2"]
def test_v4a_decoy_path_does_not_parallelize_with_real_target(self, tmp_path):
"""mode=patch scopes via V4A headers, not a decoy path= argument.
A patch that claims path=dummy.txt but updates real.py must not share
a parallel segment with write_file/read_file on real.py.
"""
patch_body = (
"*** Begin Patch\n"
"*** Update File: real.py\n"
"@@\n"
"-old\n"
"+new\n"
"*** End Patch\n"
)
patch_args = json.dumps({
"mode": "patch",
"path": "dummy.txt",
"patch": patch_body,
})
calls = [
_tc("patch", patch_args, call_id="p1"),
_tc("write_file", '{"path":"real.py","content":"x"}', call_id="w1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
assert _flatten_ids(segments) == ["p1", "w1"]
# Overlap on real.py must prevent a single parallel segment.
assert not (
len(segments) == 1
and segments[0][0] == "parallel"
and [tc.id for tc in segments[0][1]] == ["p1", "w1"]
)
# Solo runs demote to sequential and may merge; either shape is safe.
if len(segments) == 1:
assert segments[0][0] == "sequential"
else:
assert [tc.id for tc in segments[0][1]] == ["p1"]
assert [tc.id for tc in segments[1][1]] == ["w1"]
def test_v4a_multi_file_reserves_all_header_targets(self, tmp_path):
"""Multi-file V4A must reserve every Update/Add/Delete/Move target."""
patch_body = (
"*** Begin Patch\n"
"*** Update File: a.py\n"
"@@\n-a\n+b\n"
"*** Add File: b.py\n"
"+fresh\n"
"*** End Patch\n"
)
# Honest path= only names a.py — b.py still must be reserved.
patch_args = json.dumps({
"mode": "patch",
"path": "a.py",
"patch": patch_body,
})
calls = [
_tc("patch", patch_args, call_id="p1"),
_tc("read_file", '{"path":"b.py"}', call_id="r1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
assert _flatten_ids(segments) == ["p1", "r1"]
assert not (
len(segments) == 1
and segments[0][0] == "parallel"
and [tc.id for tc in segments[0][1]] == ["p1", "r1"]
)
if len(segments) == 1:
assert segments[0][0] == "sequential"
else:
assert [tc.id for tc in segments[0][1]] == ["p1"]
assert [tc.id for tc in segments[1][1]] == ["r1"]
def test_v4a_without_path_arg_still_scopes_from_headers(self, tmp_path):
"""mode=patch with no path= must still parallel-scope from V4A headers."""
patch_body = (
"*** Begin Patch\n"
"*** Update File: real.py\n"
"@@\n-old\n+new\n"
"*** End Patch\n"
)
patch_args = json.dumps({"mode": "patch", "patch": patch_body})
calls = [
_tc("patch", patch_args, call_id="p1"),
_tc("write_file", '{"path":"other.py","content":"x"}', call_id="w1"),
_tc("read_file", '{"path":"real.py"}', call_id="r1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
# p1+w1 are disjoint → can share a parallel run; r1 overlaps real.py → new run.
assert _flatten_ids(segments) == ["p1", "w1", "r1"]
assert [tc.id for tc in segments[0][1]] == ["p1", "w1"]
assert segments[0][0] == "parallel"
assert [tc.id for tc in segments[1][1]] == ["r1"]
def test_path_scoped_tool_without_path_is_a_barrier(self):
calls = [
_tc("read_file", "{}", call_id="nopath"),
_tc("web_search", call_id="r1"),
_tc("web_search", call_id="r2"),
]
segments = _plan_tool_batch_segments(calls)
assert _kinds(segments) == ["sequential", "parallel"]
def test_flattened_segments_always_preserve_emission_order(self):
calls = [
_tc("terminal", '{"command":"x"}', call_id="b1"),
_tc("web_search", call_id="r1"),
_tc("clarify", '{"question":"?"}', call_id="c1"),
_tc("read_file", '{"path":"a.py"}', call_id="r2"),
_tc("read_file", '{"path":"b.py"}', call_id="r3"),
]
segments = _plan_tool_batch_segments(calls)
assert _flatten_ids(segments) == ["b1", "r1", "c1", "r2", "r3"]
class TestReaderWriterPathRoles:
"""Reader/writer reservation semantics on path-scoped tools.
The originating bug: ``search_files`` was in ``_PARALLEL_SAFE_TOOLS``
with no path reservation, so ``patch(path=X)`` + ``search_files(path=dir(X))``
landed in ONE parallel segment and the search could observe pre-patch
file content (stale-read race). Fix: ``search_files`` reserves its
search root as a READER; overlap conflicts only when a WRITER is on
either side.
"""
def test_search_files_after_patch_same_subtree_splits(self, tmp_path, monkeypatch):
"""The exact smoke-test race: patch a file, search its directory."""
monkeypatch.chdir(tmp_path)
calls = [
_tc("patch", '{"path":"scratch/sample.txt","old_string":"a","new_string":"patched"}', call_id="w1"),
_tc("search_files", '{"pattern":"patched","path":"scratch"}', call_id="s1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
# Both calls survive, but never in the same PARALLEL segment.
# (A shared *sequential* segment is fine — sequential is ordered.)
assert _flatten_ids(segments) == ["w1", "s1"]
for kind, seg_calls in segments:
ids = [tc.id for tc in seg_calls]
assert not (kind == "parallel" and {"w1", "s1"} <= set(ids)), (
"write and dependent search must not share a parallel segment"
)
def test_search_files_default_root_conflicts_with_write_into_cwd(self, tmp_path, monkeypatch):
"""search_files with NO path arg reserves the cwd — a write anywhere
under the cwd must not share its segment."""
monkeypatch.chdir(tmp_path)
calls = [
_tc("write_file", '{"path":"out/notes.txt","content":"x"}', call_id="w1"),
_tc("search_files", '{"pattern":"notes"}', call_id="s1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
for kind, seg_calls in segments:
ids = [tc.id for tc in seg_calls]
assert not (kind == "parallel" and {"w1", "s1"} <= set(ids))
def test_reader_reader_same_file_stays_parallel(self, tmp_path, monkeypatch):
"""Two reads of the same file commute — the old planner needlessly
split them; they must now share one parallel segment."""
monkeypatch.chdir(tmp_path)
calls = [
_tc("read_file", '{"path":"a.py"}', call_id="r1"),
_tc("read_file", '{"path":"a.py"}', call_id="r2"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
assert _kinds(segments) == ["parallel"]
assert [tc.id for tc in segments[0][1]] == ["r1", "r2"]
def test_read_file_and_search_files_overlapping_stay_parallel(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = [
_tc("read_file", '{"path":"src/a.py"}', call_id="r1"),
_tc("search_files", '{"pattern":"foo","path":"src"}', call_id="s1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
assert _kinds(segments) == ["parallel"]
def test_search_files_disjoint_from_write_stays_parallel(self, tmp_path, monkeypatch):
"""A search rooted outside the written subtree has no conflict."""
monkeypatch.chdir(tmp_path)
calls = [
_tc("write_file", '{"path":"src/a.py","content":"x"}', call_id="w1"),
_tc("search_files", '{"pattern":"foo","path":"docs"}', call_id="s1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
assert _kinds(segments) == ["parallel"]
assert [tc.id for tc in segments[0][1]] == ["w1", "s1"]
def test_writer_writer_same_path_still_splits(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = [
_tc("write_file", '{"path":"a.py","content":"1"}', call_id="w1"),
_tc("write_file", '{"path":"a.py","content":"2"}', call_id="w2"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
for kind, seg_calls in segments:
ids = [tc.id for tc in seg_calls]
assert not (kind == "parallel" and {"w1", "w2"} <= set(ids))
def test_read_then_write_same_file_still_splits(self, tmp_path, monkeypatch):
"""Reader followed by writer on the same path keeps the pre-existing
split (write must not clobber a file mid-read)."""
monkeypatch.chdir(tmp_path)
calls = [
_tc("read_file", '{"path":"a.py"}', call_id="r1"),
_tc("write_file", '{"path":"a.py","content":"x"}', call_id="w1"),
]
segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
for kind, seg_calls in segments:
ids = [tc.id for tc in seg_calls]
assert not (kind == "parallel" and {"r1", "w1"} <= set(ids))
class TestShouldParallelizeBackwardCompat:
"""The boolean gate is now a view over the planner — same answers as before."""
def test_single_call_is_sequential(self):
assert not _should_parallelize_tool_batch([_tc("web_search")])
# ---------------------------------------------------------------------------
# Dispatcher integration
# ---------------------------------------------------------------------------
def _make_tool_defs(*names: str) -> list:
return [
{
"type": "function",
"function": {
"name": n,
"description": f"{n} tool",
"parameters": {"type": "object", "properties": {}},
},
}
for n in names
]
@pytest.fixture()
def agent():
with (
patch(
"model_tools.get_tool_definitions",
return_value=_make_tool_defs("web_search", "terminal"),
),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
a = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
a.client = MagicMock()
return a
class TestSegmentedDispatchIntegration:
def test_mixed_batch_runs_safe_prefix_concurrently_and_barrier_after(self, agent):
"""Two web_search calls must overlap in time; terminal must start only
after both finish; results land in the model's emission order."""
calls = [
_tc("web_search", '{"query":"a"}', call_id="s1"),
_tc("web_search", '{"query":"b"}', call_id="s2"),
_tc("terminal", '{"command":"echo done"}', call_id="t1"),
]
msg = SimpleNamespace(content="", tool_calls=calls)
messages = []
rendezvous = threading.Barrier(2, timeout=10)
events = []
events_lock = threading.Lock()
def fake_handle(name, args, task_id, **kwargs):
with events_lock:
events.append(("start", name, kwargs["tool_call_id"]))
if name == "web_search":
# Both searches must be in flight at once to pass this
# barrier — proves genuine concurrency for the safe prefix.
rendezvous.wait()
with events_lock:
events.append(("end", name, kwargs["tool_call_id"]))
return json.dumps({"ok": name})
with patch("model_tools.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls(msg, messages, "task-1")
# One result per call, in emission order.
assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1"]
assert all(m["role"] == "tool" for m in messages)
# The barrier (terminal) started only after BOTH searches ended.
terminal_start = events.index(("start", "terminal", "t1"))
search_ends = [
i for i, e in enumerate(events) if e[0] == "end" and e[1] == "web_search"
]
assert len(search_ends) == 2
assert all(i < terminal_start for i in search_ends)
def test_mixed_batch_preserves_order_with_barrier_in_middle(self, agent):
calls = [
_tc("web_search", '{"query":"a"}', call_id="s1"),
_tc("web_search", '{"query":"b"}', call_id="s2"),
_tc("terminal", '{"command":"touch x"}', call_id="t1"),
_tc("web_search", '{"query":"c"}', call_id="s3"),
_tc("web_search", '{"query":"d"}', call_id="s4"),
]
msg = SimpleNamespace(content="", tool_calls=calls)
messages = []
executed = []
lock = threading.Lock()
def fake_handle(name, args, task_id, **kwargs):
with lock:
executed.append(kwargs["tool_call_id"])
return json.dumps({"ok": True})
with patch("model_tools.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls(msg, messages, "task-1")
assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"]
# Barrier ordering: t1 executed after {s1,s2} and before {s3,s4}.
t1_pos = executed.index("t1")
assert {"s1", "s2"} == set(executed[:t1_pos])
assert {"s3", "s4"} == set(executed[t1_pos + 1:])
def test_interrupt_during_barrier_drains_later_segments(self, agent):
"""Interrupt raised while the barrier tool runs: the trailing parallel
segment must be drained with cancelled results — one per call —
without executing."""
calls = [
_tc("web_search", '{"query":"a"}', call_id="s1"),
_tc("web_search", '{"query":"b"}', call_id="s2"),
_tc("terminal", '{"command":"long"}', call_id="t1"),
_tc("web_search", '{"query":"c"}', call_id="s3"),
_tc("web_search", '{"query":"d"}', call_id="s4"),
]
msg = SimpleNamespace(content="", tool_calls=calls)
messages = []
executed = []
lock = threading.Lock()
def fake_handle(name, args, task_id, **kwargs):
with lock:
executed.append(kwargs["tool_call_id"])
if kwargs["tool_call_id"] == "t1":
agent._interrupt_requested = True
return json.dumps({"ok": True})
with patch("model_tools.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls(msg, messages, "task-1")
# Every call still gets exactly one result, in order.
assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"]
# s3/s4 were never executed.
assert "s3" not in executed and "s4" not in executed
for m in messages[-2:]:
assert "cancelled" in m["content"] or "skipped" in m["content"]
def test_steer_lands_exactly_once_in_mixed_batch(self, agent):
"""The whole-batch finalizer drains steer once, so the marker cannot
be duplicated by segment boundaries."""
calls = [
_tc("web_search", '{"query":"a"}', call_id="s1"),
_tc("web_search", '{"query":"b"}', call_id="s2"),
_tc("terminal", '{"command":"echo hi"}', call_id="t1"),
]
msg = SimpleNamespace(content="", tool_calls=calls)
messages = []
def fake_handle(name, args, task_id, **kwargs):
return json.dumps({"ok": True})
agent.steer("focus on the tests")
with patch("model_tools.handle_function_call", side_effect=fake_handle):
agent._execute_tool_calls(msg, messages, "task-1")
contents = [m["content"] for m in messages]
hits = [c for c in contents if "focus on the tests" in c]
assert len(hits) == 1
@pytest.mark.parametrize(
("calls", "expected_segment_kinds"),
[
(
[
_tc("web_search", '{"query":"large"}', call_id="parallel-large"),
_tc("web_search", '{"query":"small"}', call_id="parallel-small"),
],
["parallel"],
),
(
[
_tc("terminal", '{"command":"large"}', call_id="sequential-large"),
_tc("terminal", '{"command":"small"}', call_id="sequential-small"),
],
["sequential"],
),
(
[
_tc("web_search", '{"query":"large"}', call_id="mixed-large"),
_tc("web_search", '{"query":"small"}', call_id="mixed-search-small"),
_tc("terminal", '{"command":"small"}', call_id="mixed-terminal-small"),
],
["parallel", "sequential"],
),
(
[
_tc("web_search", '{"query":"small"}', call_id="mixed-search-first-small"),
_tc("web_search", '{"query":"small"}', call_id="mixed-search-second-small"),
_tc("terminal", '{"command":"large"}', call_id="mixed-terminal-large"),
],
["parallel", "sequential"],
),
],
ids=["parallel", "sequential", "mixed-parallel-large", "mixed-sequential-large"],
)
def test_steer_survives_turn_budget_in_every_dispatch_path(
self, agent, calls, expected_segment_kinds
):
"""A steer must be appended after aggregate budgeting in direct
concurrent, direct sequential, and segmented mixed batches.
The large result forces ``enforce_turn_budget()`` to replace it.
Before the fix, the per-tool drain consumed the steer first, so that
replacement silently discarded the canonical marker.
"""
messages = []
msg = SimpleNamespace(content="", tool_calls=calls)
budget = BudgetConfig(
default_result_size=10_000,
turn_budget=48,
preview_size=16,
)
assert _kinds(_plan_tool_batch_segments(calls)) == expected_segment_kinds
def fake_handle(name, args, task_id, **kwargs):
if kwargs["tool_call_id"].endswith("large"):
assert agent.steer("preserve this steer after budget enforcement")
return "L" * 1_000
return "small"
with (
patch("model_tools.handle_function_call", side_effect=fake_handle),
patch("agent.tool_executor._budget_for_agent", return_value=budget),
):
agent._execute_tool_calls(msg, messages, "task-1")
large_result_index = next(i for i, call in enumerate(calls) if call.id.endswith("large"))
_assert_budget_replaced(messages[large_result_index]["content"])
steer_messages = [m for m in messages if STEER_MARKER_OPEN in m["content"]]
assert steer_messages == [messages[-1]]
assert "preserve this steer after budget enforcement" in steer_messages[0]["content"]
def test_steer_survives_turn_budget_after_malformed_arguments(self, agent):
"""Malformed arguments still reach the shared post-budget finalizer.
The parser error itself can exceed a constrained turn budget. A steer
queued before that malformed sequential call must therefore remain
pending until after the error result is replaced by the budget preview.
"""
calls = [_tc("terminal", "{not json", call_id="malformed")]
messages = []
msg = SimpleNamespace(content="", tool_calls=calls)
budget = BudgetConfig(
default_result_size=10_000,
turn_budget=48,
preview_size=16,
)
assert _kinds(_plan_tool_batch_segments(calls)) == ["sequential"]
assert agent.steer("preserve malformed-call steer after budget enforcement")
with patch("agent.tool_executor._budget_for_agent", return_value=budget):
agent._execute_tool_calls(msg, messages, "task-1")
assert len(messages) == 2
_assert_budget_replaced(messages[0]["content"])
assert STEER_MARKER_OPEN not in messages[0]["content"] # tool row untouched
assert messages[1]["role"] == "user" # steer = new user msg
assert messages[1]["content"].count(STEER_MARKER_OPEN) == 1
assert "preserve malformed-call steer after budget enforcement" in messages[1]["content"]
class TestPathCanonicalization:
"""Regression tests for _canonical_path / _extract_parallel_scope_path fixes.
Verifies that symlink aliases, relative/absolute cwd mismatches, and
(on Windows) case-insensitive aliases are never placed in the same
parallel segment.
"""
def test_relative_and_absolute_same_target_use_separate_segments(self, tmp_path):
"""A relative path resolved against execution_cwd and an absolute path
pointing to the same file must be detected as overlapping."""
from agent.tool_dispatch_helpers import (
_canonical_path,
_paths_overlap,
)
target = tmp_path / "config.json"
target.touch()
abs_path = _canonical_path(str(target))
rel_path = _canonical_path("config.json", execution_cwd=tmp_path)
assert _paths_overlap(abs_path, rel_path), (
"Absolute and relative paths pointing to the same file must overlap"
)
@pytest.mark.require_symlinks
def test_symlink_aliases_are_not_parallelized(self, tmp_path):
"""A symlink alias and the real path must be detected as overlapping
so they are never placed in the same parallel segment."""
from agent.tool_dispatch_helpers import (
_canonical_path,
_paths_overlap,
)
real_dir = tmp_path / "real"
real_dir.mkdir()
target = real_dir / "config.json"
target.touch()
alias_dir = tmp_path / "alias"
alias_dir.symlink_to(real_dir)
real_path = _canonical_path(str(target))
alias_path = _canonical_path(str(alias_dir / "config.json"))
assert _paths_overlap(real_path, alias_path), (
"Symlink alias and real path must overlap — "
"they must not be parallelized"
)
def test_execution_cwd_used_over_process_cwd(self, tmp_path, monkeypatch):
"""_extract_parallel_scope_path must use execution_cwd, not
process cwd, when resolving relative paths."""
from agent.tool_dispatch_helpers import (
_extract_parallel_scope_path,
_paths_overlap,
)
exec_cwd = tmp_path / "sub"
exec_cwd.mkdir()
(exec_cwd / "x.txt").touch()
# Point process cwd somewhere else entirely.
monkeypatch.chdir(tmp_path)
# With execution_cwd supplied the relative path resolves under exec_cwd.
path_with_cwd = _extract_parallel_scope_path(
"write_file", {"path": "x.txt"}, execution_cwd=exec_cwd
)
# The absolute path under exec_cwd must match.
path_absolute = _extract_parallel_scope_path(
"write_file", {"path": str(exec_cwd / "x.txt")}
)
assert path_with_cwd is not None
assert path_absolute is not None
assert _paths_overlap(path_with_cwd, path_absolute), (
"execution_cwd-relative path and absolute path must overlap; "
"process cwd must not be used when execution_cwd is provided"
)
# ``platforms("windows")`` rather than ``skipif(sys.platform != "win32")``: the
# Windows CI job greps for the marker to decide which files to import, so
# a bare skipif leaves this running on no host at all.
@pytest.mark.platforms("windows")
def test_case_insensitive_paths_overlap_windows(self, tmp_path):
"""On Windows, FILE.txt and file.txt are the same file — they must
be detected as overlapping after normcase() canonicalisation."""
from agent.tool_dispatch_helpers import _canonical_path, _paths_overlap
upper = _canonical_path(str(tmp_path / "FILE.txt"), execution_cwd=tmp_path)
lower = _canonical_path(str(tmp_path / "file.txt"), execution_cwd=tmp_path)
assert _paths_overlap(upper, lower), (
"Case-insensitive aliases must overlap on Windows"
)