diff --git a/agent/agent_init.py b/agent/agent_init.py index 3a648c1b955..0e5db2c0ce1 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1224,6 +1224,12 @@ def init_agent( # targets. agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) + # Universal parallel-tool-call guidance toggle. Default True. Separate + # flag from task_completion_guidance because a user may want one but not + # the other. Steers the model to batch independent tool calls into a + # single turn; the runtime already executes such batches concurrently. + agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True)) + # Local Python toolchain probe toggle. Default True. When False, # the probe is skipped entirely (no subprocess calls, no system-prompt # line). Useful for users on exotic setups where the probe heuristics diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index bbae3c9a773..b8e60722168 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -305,6 +305,45 @@ TASK_COMPLETION_GUIDANCE = ( "is always better than inventing a result." ) +# Universal parallel-tool-call guidance — applied to ALL models. +# +# Why this matters for cost: every assistant turn resends the entire +# accumulated conversation (and, on cache-friendly providers, re-reads the +# cached prefix and pays for the newly-appended turn). A model that issues +# one tool call per turn multiplies the number of round-trips — and therefore +# the resent context — for any task that needs several independent reads, +# searches, or safe lookups. Batching independent calls into a single +# assistant response collapses N turns into one, cutting both latency and the +# resent-context cost that compounds over a long conversation. +# +# The hermes-agent runtime already executes a batch of tool calls +# concurrently when they are independent (read-only tools always; path-scoped +# file ops when their targets don't overlap — see +# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece +# was telling the *model* to emit those calls together in the first place; +# nothing in the open-source system prompt encouraged batching. This block +# closes that gap. +# +# Short on purpose — shipped in the cached system prompt to every user, every +# session. Token cost is paid once at install and amortised across all +# sessions via prefix caching. Keep it tight. +# +# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted +# from Cline's TypeScript tool-surface guidance to hermes-agent's Python +# prompt-assembly architecture. +PARALLEL_TOOL_CALL_GUIDANCE = ( + "# Parallel tool calls\n" + "When you need several pieces of information that don't depend on each " + "other, request them together in a single response instead of one tool " + "call per turn. Independent reads, searches, web fetches, and read-only " + "commands should be batched into the same assistant turn — the runtime " + "executes independent calls concurrently, and batching avoids resending " + "the whole conversation on every extra round-trip.\n" + "Only serialize calls when a later call genuinely depends on an earlier " + "call's result (e.g. you must read a file before you can patch it). When " + "in doubt and the calls are independent, batch them." +) + # OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes # where GPT models abandon work on partial results, skip prerequisite lookups, # hallucinate instead of using tools, and declare "done" without verification. diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b3f39123fd5..281f01399b4 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -33,6 +33,7 @@ from agent.prompt_builder import ( KANBAN_GUIDANCE, MEMORY_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE, + PARALLEL_TOOL_CALL_GUIDANCE, PLATFORM_HINTS, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE, @@ -123,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names: stable_parts.append(TASK_COMPLETION_GUIDANCE) + # Universal parallel-tool-call guidance. Tells the model to batch + # independent tool calls into one assistant turn rather than emitting one + # call per turn — the runtime already runs independent calls concurrently + # (read-only tools always; non-overlapping path-scoped file ops), so the + # only thing missing was steering the model to produce the batch. Cuts + # round-trips and the resent-context cost that compounds over a long + # conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance`` + # (default True) and only injected when tools are actually loaded. + if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names: + stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE) + # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] if "memory" in agent.valid_tool_names: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 356839f9903..9a370c53a86 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -853,6 +853,15 @@ DEFAULT_CONFIG = { # plausible-looking output when a real path is blocked. Costs ~80 # tokens in the cached system prompt. Set False to disable globally. "task_completion_guidance": True, + # Universal parallel-tool-call guidance — short prompt block applied to + # all models that tells the model to batch independent tool calls + # (reads, searches, web fetches, read-only commands) into one turn + # instead of one call per turn. The runtime already runs independent + # calls concurrently, so this just steers the model to produce the + # batch — cutting round-trips and the resent-context cost that + # compounds over a long conversation. Costs ~70 tokens in the cached + # system prompt. Set False to disable globally. + "parallel_tool_call_guidance": True, # Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668 # state in the system prompt when something non-default is detected # (e.g. python3 has no pip module, pip→python version mismatch, PEP diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 4eb2f86e5a2..e98c26e319f 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -27,6 +27,7 @@ from agent.prompt_builder import ( TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, OPENAI_MODEL_EXECUTION_GUIDANCE, + PARALLEL_TOOL_CALL_GUIDANCE, MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, @@ -1497,6 +1498,43 @@ class TestOpenAIModelExecutionGuidance: assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100 +class TestParallelToolCallGuidance: + """Behavior contracts for the universal parallel-tool-call guidance block. + + Asserts the invariants the block must satisfy (steer batching, scope to + independent calls, stay short for the cached prompt) rather than freezing + its exact wording. + """ + + def test_is_nonempty_string(self): + assert isinstance(PARALLEL_TOOL_CALL_GUIDANCE, str) + assert PARALLEL_TOOL_CALL_GUIDANCE.strip() + + def test_steers_batching_into_one_response(self): + text = PARALLEL_TOOL_CALL_GUIDANCE.lower() + # Must tell the model to group independent calls together. + assert "single response" in text or "same" in text and "turn" in text + assert "independent" in text + + def test_carves_out_dependent_calls(self): + # Must NOT tell the model to batch dependent calls — that would break + # ordering (read-before-patch). The block has to acknowledge the + # serialize-when-dependent case. + text = PARALLEL_TOOL_CALL_GUIDANCE.lower() + assert "depend" in text + + def test_stays_short_for_cached_prompt(self): + # Shipped in every cached system prompt — keep it tight. The existing + # task-completion block is ~600 chars; allow generous headroom but + # guard against accidental essay growth. + assert len(PARALLEL_TOOL_CALL_GUIDANCE) < 900 + + def test_has_a_heading(self): + # Heading delimits it as its own section in the assembled prompt. + assert PARALLEL_TOOL_CALL_GUIDANCE.lstrip().startswith("#") + + + # ========================================================================= # Budget warning history stripping # =========================================================================