Skip to content

Secret placeholders not substituted inside parallel tool's nested tool_calls #1838

Description

@YorVeX

Secret placeholders not substituted inside parallel tool's nested tool_calls

Description

Secret alias placeholders (e.g. §§secret(KEY)) are correctly substituted when used in top-level tool arguments of a direct tool call. However, when the same placeholders appear inside the nested tool_calls array of the parallel tool, they are not substituted — the literal placeholder string is passed through to the inner tool, causing authentication failures and other errors.

Root Cause

The substitution is handled by the tool_execute_before extension:

extensions/python/tool_execute_before/_10_unmask_secrets.py

class UnmaskToolSecrets(Extension):
    async def execute(self, **kwargs):
        if not self.agent:
            return
        tool_args = kwargs.get("tool_args")
        if not tool_args:
            return
        secrets_mgr = get_secrets_manager(self.agent.context)
        for k, v in tool_args.items():
            if isinstance(v, str):                    # <-- only top-level strings
                tool_args[k] = secrets_mgr.replace_placeholders(v)

The loop only processes top-level string values (isinstance(v, str)). When the parallel tool is called, its tool_calls argument is a list of dicts — not a string — so it is skipped entirely. The nested tool_args inside each tool_calls entry are never traversed, and any §§secret(KEY) placeholders within them reach the inner tool as literal text.

The inner tool's execute_tool_call() in helpers/parallel_tools.py does call tool_execute_before again, but by that point the tool_args have already been passed through unchanged (the substitution only modifies top-level keys, and the worker reuses the same unmodified args).

Contrast with §§include()

The §§include() placeholder does work inside parallel because its substitution extension (extensions/python/response_stream/_15_replace_include_alias.py) uses a recursive replacement function:

def replace_placeholders(value: Any) -> Any:
    if isinstance(value, str):
        new_val = replace_file_includes(new_val, r"§§include\\(([^)]+)\\)")
        return new_val
    if isinstance(value, dict):
        return {k: replace_placeholders(v) for k, v in value.items()}
    if isinstance(value, list):
        return [replace_placeholders(v) for v in value]
    ...

This inconsistency means §§include() works recursively but §§secret() does not.

Steps to Reproduce

  1. Store a secret, e.g. §§secret(SOME_SECRET_TOKEN) in secrets.env.
  2. Call code_execution_tool directly with the placeholder in the code arg — works (placeholder is substituted).
  3. Call parallel with the same code_execution_tool nested inside tool_callsfails (literal placeholder text is sent to the tool).

Failing example

{
  "tool_name": "parallel",
  "tool_args": {
    "tool_calls": [
      {
        "tool_name": "code_execution_tool",
        "tool_args": {
          "code": "curl -H 'Authorization: Bearer §§secret(SOME_SECRET_TOKEN)' https://example.com/api"
        }
      }
    ]
  }
}

The curl command receives the literal string §§secret(SOME_SECRET_TOKEN) instead of the actual token value.

Expected Behavior

§§secret() placeholders should be substituted recursively in all tool arguments, including nested structures inside parallel.tool_calls[], just as §§include() already does.

Suggested Fix

Make _10_unmask_secrets.py recursive, mirroring the approach in _15_replace_include_alias.py:

from helpers.secrets import get_secrets_manager
from typing import Any


def _replace_placeholders(value: Any, secrets_mgr) -> Any:
    if isinstance(value, str):
        return secrets_mgr.replace_placeholders(value)
    if isinstance(value, dict):
        return {k: _replace_placeholders(v, secrets_mgr) for k, v in value.items()}
    if isinstance(value, list):
        return [_replace_placeholders(v, secrets_mgr) for v in value]
    if isinstance(value, tuple):
        return tuple(_replace_placeholders(v, secrets_mgr) for v in value)
    return value


class UnmaskToolSecrets(Extension):
    async def execute(self, **kwargs):
        if not self.agent:
            return
        tool_args = kwargs.get("tool_args")
        if not tool_args:
            return
        secrets_mgr = get_secrets_manager(self.agent.context)
        for k, v in tool_args.items():
            tool_args[k] = _replace_placeholders(v, secrets_mgr)

Impact

Any workflow that uses parallel to batch independent API calls with secret-based authentication will fail. The workaround is to run such calls sequentially instead of in parallel, which defeats the purpose of the parallel tool.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions