Repository: huggingface/smolagents

Issue:
[BUG] Error during jinja template rendering: UndefinedError: 'tool_descriptions' is undefined
**Describe the bug**

I have the message " Error during jinja template rendering: UndefinedError: 'tool_descriptions' is undefined " when i customize the prompt 

**Code to reproduce the error**

```
import os
import fitz

from smolagents import Tool, LiteLLMModel, CodeAgent, PromptTemplates, PlanningPromptTemplate
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.retrievers import BM25Retriever

model = LiteLLMModel(model_id="ollama_chat/llama3.2")

class Document:
    def __init__(self, page_content, metadata, doc_id):
        self.page_content = page_content
        self.metadata = metadata
        self.doc_id = doc_id

class RetrieveTool(Tool):
    name = "retriever"
    description = "Uses semantic search to retrieve relevant parts of the documents."
    inputs = {
        "query": {
            "type": "string",
            "description": "The query to perform. Use affirmative sentences rather than questions."
        }
    }
    output_type = "string"

    def __init__(self, docs, **kwargs):
        super().__init__(**kwargs)
        self.retriever = BM25Retriever.from_documents(docs, k=10)

    def forward(self, query: str) -> str:
        assert isinstance(query, str), "Your search query must be a string."
        docs = self.retriever.invoke(query)
        return "\nRetrieved documents:\n" + "".join([
            f"\n\n===== Document {str(i)} =====\n" + doc.page_content
            for i, doc in enumerate(docs)
        ])

def load_docs(directory):
    documents = []
    for idx, filename in enumerate(os.listdir(directory)):
        if filename.endswith(".pdf"):
            file_path = os.path.join(directory, filename)
            with fitz.open(file_path) as pdf_document:
                page_content = ""
                for page in pdf_document:
                    page_content += page.get_text("text")
                documents.append(Document(page_content, {"source": file_path}, doc_id=str(idx)))
    return documents

def split_docs(documents, chunk_size=500, chunk_overlap=20):
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
    docs = text_splitter.split_documents(documents)
    return docs

directory = "/home/appikal/Downloads/CVs"
docs = load_docs(directory)
docs_processed = split_docs(docs)

retriever_tool = RetrieveTool(docs_processed)

PROMPT: str = """
You are an expert assistant specialized in solving complex tasks using code execution and external tools. Your primary function is to retrieve relevant information from documents and then process it as needed.  

### Workflow:
To efficiently solve tasks, follow a structured loop of `Thought:`, `Code:`, and `Observation:` sequences:

1. **Thought:** Explain your reasoning and plan for solving the task. ALWAYS prioritize retrieving relevant information first using the `retriever` tool before any other action.
2. **Code:** Write Python code to execute the plan, ensuring that:
   - The code is simple, clear, and efficient.
   - The sequence ends with `<end_code>`.
   - Use `print()` statements to expose important intermediate results.
3. **Observation:** Use the printed outputs from the execution to refine your next steps.

### Tool Usage Guidelines:
- **ALWAYS call `retriever` first** before attempting any other operations if document retrieval is relevant.
- Use only variables that have been previously defined.
- Pass tool arguments explicitly (e.g., `answer = wiki(query="Who is James Bond?")` instead of using dictionaries).
- Avoid chaining multiple tool calls in a single execution when output formats are unpredictable.
- Do not duplicate tool calls with the same parameters.
- Never name variables after tool names (e.g., avoid `final_answer` as a variable name).
- Imports are allowed only from the following modules: {{authorized_imports}}.
- The state persists between executions, so variables and imports remain available.

### Available Tools:

{{tool_descriptions}}

{{managed_agents_descriptions}}

### Example Usage:
---
{examples}
---

Now begin! If you complete the task correctly, you will receive a reward of $1,000,000.

"""

agent = CodeAgent(
    tools=[retriever_tool], model=model
)
agent.prompt_templates["system_prompt"]= PROMPT
#print(agent.prompt_templates)
agent_output = agent.run("Ou a travaillé Adrien ?")

#print('Number of documents: ', len(docs))
#print('Number of chunks: ', len(docs_processed))
```

**Expected behavior**
Should run correctly

**Packages version:**
`smolagents==1.10.0`



Discussion:
HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/smolagents/pr_900). All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Patch:
diff --git a/docs/source/en/tutorials/building_good_agents.mdx b/docs/source/en/tutorials/building_good_agents.mdx
index 8c17de1..97e257a 100644
--- a/docs/source/en/tutorials/building_good_agents.mdx
+++ b/docs/source/en/tutorials/building_good_agents.mdx
@@ -43,7 +43,7 @@ This leads to a few takeaways:
 
 ### Improve the information flow to the LLM engine
 
-Remember that your LLM engine is like an *intelligent* robot, tapped into a room with the only communication with the outside world being notes passed under a door.
+Remember that your LLM engine is like an *intelligent* robot, trapped into a room with the only communication with the outside world being notes passed under a door.
 
 It won't know of anything that happened if you don't explicitly put that into its prompt.
 
@@ -210,13 +210,153 @@ In the end you have to return a final answer using the `final_answer` tool.
 
 Here are a few examples using notional tools:
 ---
-{examples}
+Task: "Generate an image of the oldest person in this document."
 
-Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+Thought: I will proceed step by step and use the following tools: `document_qa` to find the oldest person in the document, then `image_generator` to generate an image according to the answer.
+Code:
+```py
+answer = document_qa(document=document, question="Who is the oldest person mentioned?")
+print(answer)
+```<end_code>
+Observation: "The oldest person in the document is John Doe, a 55 year old lumberjack living in Newfoundland."
+
+Thought: I will now generate an image showcasing the oldest person.
+Code:
+```py
+image = image_generator("A portrait of John Doe, a 55-year-old man living in Canada.")
+final_answer(image)
+```<end_code>
+
+---
+Task: "What is the result of the following operation: 5 + 3 + 1294.678?"
+
+Thought: I will use python code to compute the result of the operation and then return the final answer using the `final_answer` tool
+Code:
+```py
+result = 5 + 3 + 1294.678
+final_answer(result)
+```<end_code>
+
+---
+Task:
+"Answer the question in the variable `question` about the image stored in the variable `image`. The question is in French.
+You have been provided with these additional arguments, that you can access using the keys as variables in your python code:
+{'question': 'Quel est l'animal sur l'image?', 'image': 'path/to/image.jpg'}"
+
+Thought: I will use the following tools: `translator` to translate the question into English and then `image_qa` to answer the question on the input image.
+Code:
+```py
+translated_question = translator(question=question, src_lang="French", tgt_lang="English")
+print(f"The translated question is {translated_question}.")
+answer = image_qa(image=image, question=translated_question)
+final_answer(f"The answer is {answer}")
+```<end_code>
+
+---
+Task:
+In a 1979 interview, Stanislaus Ulam discusses with Martin Sherwin about other great physicists of his time, including Oppenheimer.
+What does he say was the consequence of Einstein learning too much math on his creativity, in one word?
+
+Thought: I need to find and read the 1979 interview of Stanislaus Ulam with Martin Sherwin.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein")
+print(pages)
+```<end_code>
+Observation:
+No result found for query "1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein".
+
+Thought: The query was maybe too restrictive and did not find any results. Let's try again with a broader query.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam")
+print(pages)
+```<end_code>
+Observation:
+Found 6 pages:
+[Stanislaus Ulam 1979 interview](https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/)
 
-{{tool_descriptions}}
+[Ulam discusses Manhattan Project](https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/)
 
-{{managed_agents_descriptions}}
+(truncated)
+
+Thought: I will read the first 2 pages to know more.
+Code:
+```py
+for url in ["https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/", "https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/"]:
+    whole_page = visit_webpage(url)
+    print(whole_page)
+    print("\n" + "="*80 + "\n")  # Print separator between pages
+```<end_code>
+Observation:
+Manhattan Project Locations:
+Los Alamos, NM
+Stanislaus Ulam was a Polish-American mathematician. He worked on the Manhattan Project at Los Alamos and later helped design the hydrogen bomb. In this interview, he discusses his work at
+(truncated)
+
+Thought: I now have the final answer: from the webpages visited, Stanislaus Ulam says of Einstein: "He learned too much mathematics and sort of diminished, it seems to me personally, it seems to me his purely physics creativity." Let's answer in one word.
+Code:
+```py
+final_answer("diminished")
+```<end_code>
+
+---
+Task: "Which city has the highest population: Guangzhou or Shanghai?"
+
+Thought: I need to get the populations for both cities and compare them: I will use the tool `search` to get the population of both cities.
+Code:
+```py
+for city in ["Guangzhou", "Shanghai"]:
+    print(f"Population {city}:", search(f"{city} population")
+```<end_code>
+Observation:
+Population Guangzhou: ['Guangzhou has a population of 15 million inhabitants as of 2021.']
+Population Shanghai: '26 million (2019)'
+
+Thought: Now I know that Shanghai has the highest population.
+Code:
+```py
+final_answer("Shanghai")
+```<end_code>
+
+---
+Task: "What is the current age of the pope, raised to the power 0.36?"
+
+Thought: I will use the tool `wiki` to get the age of the pope, and confirm that with a web search.
+Code:
+```py
+pope_age_wiki = wiki(query="current pope age")
+print("Pope age as per wikipedia:", pope_age_wiki)
+pope_age_search = web_search(query="current pope age")
+print("Pope age as per google search:", pope_age_search)
+```<end_code>
+Observation:
+Pope age: "The pope Francis is currently 88 years old."
+
+Thought: I know that the pope is 88 years old. Let's compute the result using python code.
+Code:
+```py
+pope_current_age = 88 ** 0.36
+final_answer(pope_current_age)
+```<end_code>
+
+Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+{%- for tool in tools.values() %}
+- {{ tool.name }}: {{ tool.description }}
+    Takes inputs: {{tool.inputs}}
+    Returns an output of type: {{tool.output_type}}
+{%- endfor %}
+
+{%- if managed_agents and managed_agents.values() | list %}
+You can also give tasks to team members.
+Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+Given that this team member is a real human, you should be very verbose in your task.
+Here is a list of the team members that you can call:
+{%- for agent in managed_agents.values() %}
+- {{ agent.name }}: {{ agent.description }}
+{%- endfor %}
+{%- else %}
+{%- endif %}
 
 Here are the rules you should always follow to solve your task:
 1. Always provide a 'Thought:' sequence, and a 'Code:\n```py' sequence ending with '```<end_code>' sequence, else you will fail.
@@ -225,7 +365,7 @@ Here are the rules you should always follow to solve your task:
 4. Take care to not chain too many sequential tool calls in the same code block, especially when the output format is unpredictable. For instance, a call to search has an unpredictable return format, so do not have another tool call that depends on its output in the same block: rather output results with print() to use them in the next block.
 5. Call a tool only when needed, and never re-do a tool call that you previously did with the exact same parameters.
 6. Don't name any new variable with the same name as a tool: for instance don't name a variable 'final_answer'.
-7. Never create any notional variables in our code, as having these in your logs might derail you from the true variables.
+7. Never create any notional variables in our code, as having these in your logs will derail you from the true variables.
 8. You can use imports in your code, but only from the following list of modules: {{authorized_imports}}
 9. The state persists between code executions: so if in one step you've created variables or imported modules, these will all persist.
 10. Don't give up! You're in charge of solving the task, not providing directions to solve it.
@@ -233,12 +373,30 @@ Here are the rules you should always follow to solve your task:
 Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
 ```
 
-As you can see, there are placeholders like `"{{tool_descriptions}}"`: these will be used upon agent initialization to insert certain automatically generated descriptions of tools or managed agents.
-
-So while you can overwrite this system prompt template by passing your custom prompt as an argument to the `system_prompt` parameter, your new system prompt must contain the following placeholders:
-- `"{{tool_descriptions}}"` to insert tool descriptions.
-- `"{{managed_agents_description}}"` to insert the description for managed agents if there are any.
-- For `CodeAgent` only: `"{{authorized_imports}}"` to insert the list of authorized imports.
+As you can see, there are placeholders like `"{{ tool.description }}"`: these will be used upon agent initialization to insert certain automatically generated descriptions of tools or managed agents.
+
+So while you can overwrite this system prompt template by passing your custom prompt as an argument to the `system_prompt` parameter, your new system prompt can contain the following placeholders:
+- To insert tool descriptions:
+  ```
+  {%- for tool in tools.values() %}
+  - {{ tool.name }}: {{ tool.description }}
+      Takes inputs: {{tool.inputs}}
+      Returns an output of type: {{tool.output_type}}
+  {%- endfor %}
+  ```
+- To insert the descriptions for managed agents if there are any:
+  ```
+  {%- if managed_agents and managed_agents.values() | list %}
+  You can also give tasks to team members.
+  Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+  Given that this team member is a real human, you should be very verbose in your task.
+  Here is a list of the team members that you can call:
+  {%- for agent in managed_agents.values() %}
+  - {{ agent.name }}: {{ agent.description }}
+  {%- endfor %}
+  {%- endif %}
+  ```
+- For `CodeAgent` only, to insert the list of authorized imports: `"{{authorized_imports}}"`
 
 Then you can change the system prompt as follows:
 
diff --git a/docs/source/hi/tutorials/building_good_agents.mdx b/docs/source/hi/tutorials/building_good_agents.mdx
index 92587ef..47ce8ac 100644
--- a/docs/source/hi/tutorials/building_good_agents.mdx
+++ b/docs/source/hi/tutorials/building_good_agents.mdx
@@ -211,13 +211,152 @@ In the end you have to return a final answer using the `final_answer` tool.
 
 Here are a few examples using notional tools:
 ---
-{examples}
+Task: "Generate an image of the oldest person in this document."
 
-Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+Thought: I will proceed step by step and use the following tools: `document_qa` to find the oldest person in the document, then `image_generator` to generate an image according to the answer.
+Code:
+```py
+answer = document_qa(document=document, question="Who is the oldest person mentioned?")
+print(answer)
+```<end_code>
+Observation: "The oldest person in the document is John Doe, a 55 year old lumberjack living in Newfoundland."
+
+Thought: I will now generate an image showcasing the oldest person.
+Code:
+```py
+image = image_generator("A portrait of John Doe, a 55-year-old man living in Canada.")
+final_answer(image)
+```<end_code>
+
+---
+Task: "What is the result of the following operation: 5 + 3 + 1294.678?"
+
+Thought: I will use python code to compute the result of the operation and then return the final answer using the `final_answer` tool
+Code:
+```py
+result = 5 + 3 + 1294.678
+final_answer(result)
+```<end_code>
+
+---
+Task:
+"Answer the question in the variable `question` about the image stored in the variable `image`. The question is in French.
+You have been provided with these additional arguments, that you can access using the keys as variables in your python code:
+{'question': 'Quel est l'animal sur l'image?', 'image': 'path/to/image.jpg'}"
+
+Thought: I will use the following tools: `translator` to translate the question into English and then `image_qa` to answer the question on the input image.
+Code:
+```py
+translated_question = translator(question=question, src_lang="French", tgt_lang="English")
+print(f"The translated question is {translated_question}.")
+answer = image_qa(image=image, question=translated_question)
+final_answer(f"The answer is {answer}")
+```<end_code>
+
+---
+Task:
+In a 1979 interview, Stanislaus Ulam discusses with Martin Sherwin about other great physicists of his time, including Oppenheimer.
+What does he say was the consequence of Einstein learning too much math on his creativity, in one word?
+
+Thought: I need to find and read the 1979 interview of Stanislaus Ulam with Martin Sherwin.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein")
+print(pages)
+```<end_code>
+Observation:
+No result found for query "1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein".
+
+Thought: The query was maybe too restrictive and did not find any results. Let's try again with a broader query.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam")
+print(pages)
+```<end_code>
+Observation:
+Found 6 pages:
+[Stanislaus Ulam 1979 interview](https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/)
+
+[Ulam discusses Manhattan Project](https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/)
+
+(truncated)
+
+Thought: I will read the first 2 pages to know more.
+Code:
+```py
+for url in ["https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/", "https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/"]:
+    whole_page = visit_webpage(url)
+    print(whole_page)
+    print("\n" + "="*80 + "\n")  # Print separator between pages
+```<end_code>
+Observation:
+Manhattan Project Locations:
+Los Alamos, NM
+Stanislaus Ulam was a Polish-American mathematician. He worked on the Manhattan Project at Los Alamos and later helped design the hydrogen bomb. In this interview, he discusses his work at
+(truncated)
+
+Thought: I now have the final answer: from the webpages visited, Stanislaus Ulam says of Einstein: "He learned too much mathematics and sort of diminished, it seems to me personally, it seems to me his purely physics creativity." Let's answer in one word.
+Code:
+```py
+final_answer("diminished")
+```<end_code>
+
+---
+Task: "Which city has the highest population: Guangzhou or Shanghai?"
 
-{{tool_descriptions}}
+Thought: I need to get the populations for both cities and compare them: I will use the tool `search` to get the population of both cities.
+Code:
+```py
+for city in ["Guangzhou", "Shanghai"]:
+    print(f"Population {city}:", search(f"{city} population")
+```<end_code>
+Observation:
+Population Guangzhou: ['Guangzhou has a population of 15 million inhabitants as of 2021.']
+Population Shanghai: '26 million (2019)'
+
+Thought: Now I know that Shanghai has the highest population.
+Code:
+```py
+final_answer("Shanghai")
+```<end_code>
+
+---
+Task: "What is the current age of the pope, raised to the power 0.36?"
 
-{{managed_agents_descriptions}}
+Thought: I will use the tool `wiki` to get the age of the pope, and confirm that with a web search.
+Code:
+```py
+pope_age_wiki = wiki(query="current pope age")
+print("Pope age as per wikipedia:", pope_age_wiki)
+pope_age_search = web_search(query="current pope age")
+print("Pope age as per google search:", pope_age_search)
+```<end_code>
+Observation:
+Pope age: "The pope Francis is currently 88 years old."
+
+Thought: I know that the pope is 88 years old. Let's compute the result using python code.
+Code:
+```py
+pope_current_age = 88 ** 0.36
+final_answer(pope_current_age)
+```<end_code>
+
+Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+{%- for tool in tools.values() %}
+- {{ tool.name }}: {{ tool.description }}
+    Takes inputs: {{tool.inputs}}
+    Returns an output of type: {{tool.output_type}}
+{%- endfor %}
+
+{%- if managed_agents and managed_agents.values() | list %}
+You can also give tasks to team members.
+Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+Given that this team member is a real human, you should be very verbose in your task.
+Here is a list of the team members that you can call:
+{%- for agent in managed_agents.values() %}
+- {{ agent.name }}: {{ agent.description }}
+{%- endfor %}
+{%- endif %}
 
 Here are the rules you should always follow to solve your task:
 1. Always provide a 'Thought:' sequence, and a 'Code:\n```py' sequence ending with '```<end_code>' sequence, else you will fail.
@@ -226,7 +365,7 @@ Here are the rules you should always follow to solve your task:
 4. Take care to not chain too many sequential tool calls in the same code block, especially when the output format is unpredictable. For instance, a call to search has an unpredictable return format, so do not have another tool call that depends on its output in the same block: rather output results with print() to use them in the next block.
 5. Call a tool only when needed, and never re-do a tool call that you previously did with the exact same parameters.
 6. Don't name any new variable with the same name as a tool: for instance don't name a variable 'final_answer'.
-7. Never create any notional variables in our code, as having these in your logs might derail you from the true variables.
+7. Never create any notional variables in our code, as having these in your logs will derail you from the true variables.
 8. You can use imports in your code, but only from the following list of modules: {{authorized_imports}}
 9. The state persists between code executions: so if in one step you've created variables or imported modules, these will all persist.
 10. Don't give up! You're in charge of solving the task, not providing directions to solve it.
@@ -234,11 +373,29 @@ Here are the rules you should always follow to solve your task:
 Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
 ```
 
-जैसा कि आप देख सकते हैं, `"{{tool_descriptions}}"` जैसे प्लेसहोल्डर्स हैं: इनका उपयोग एजेंट इनिशियलाइजेशन के समय टूल्स या मैनेज्ड एजेंट्स के कुछ स्वचालित रूप से जनरेट किए गए विवरणों को डालने के लिए किया जाएगा।
+जैसा कि आप देख सकते हैं, `"{{ tool.description }}"` जैसे प्लेसहोल्डर्स हैं: इनका उपयोग एजेंट इनिशियलाइजेशन के समय टूल्स या मैनेज्ड एजेंट्स के कुछ स्वचालित रूप से जनरेट किए गए विवरणों को डालने के लिए किया जाएगा।
 
 इसलिए जबकि आप `system_prompt` पैरामीटर में अपने कस्टम प्रॉम्प्ट को आर्गुमेंट के रूप में पास करके इस सिस्टम प्रॉम्प्ट टेम्पलेट को ओवरराइट कर सकते हैं, आपके नए सिस्टम प्रॉम्प्ट में निम्नलिखित प्लेसहोल्डर्स होने चाहिए:
-- टूल विवरण डालने के लिए `"{{tool_descriptions}}"`।
-- यदि कोई मैनेज्ड एजेंट्स हैं तो उनके लिए विवरण डालने के लिए `"{{managed_agents_description}}"`।
+- टूल विवरण डालने के लिए।
+  ```
+  {%- for tool in tools.values() %}
+  - {{ tool.name }}: {{ tool.description }}
+      Takes inputs: {{tool.inputs}}
+      Returns an output of type: {{tool.output_type}}
+  {%- endfor %}
+  ```
+- यदि कोई मैनेज्ड एजेंट्स हैं तो उनके लिए विवरण डालने के लिए।
+  ```
+  {%- if managed_agents and managed_agents.values() | list %}
+  You can also give tasks to team members.
+  Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+  Given that this team member is a real human, you should be very verbose in your task.
+  Here is a list of the team members that you can call:
+  {%- for agent in managed_agents.values() %}
+  - {{ agent.name }}: {{ agent.description }}
+  {%- endfor %}
+  {%- endif %}
+  ```
 - केवल `CodeAgent` के लिए: अधिकृत इम्पोर्ट्स की सूची डालने के लिए `"{{authorized_imports}}"`।
 
 फिर आप सिस्टम प्रॉम्प्ट को निम्नानुसार बदल सकते हैं:
diff --git a/docs/source/zh/tutorials/building_good_agents.mdx b/docs/source/zh/tutorials/building_good_agents.mdx
index fbf489f..0dd7113 100644
--- a/docs/source/zh/tutorials/building_good_agents.mdx
+++ b/docs/source/zh/tutorials/building_good_agents.mdx
@@ -209,13 +209,152 @@ In the end you have to return a final answer using the `final_answer` tool.
 
 Here are a few examples using notional tools:
 ---
-{examples}
+Task: "Generate an image of the oldest person in this document."
 
-Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+Thought: I will proceed step by step and use the following tools: `document_qa` to find the oldest person in the document, then `image_generator` to generate an image according to the answer.
+Code:
+```py
+answer = document_qa(document=document, question="Who is the oldest person mentioned?")
+print(answer)
+```<end_code>
+Observation: "The oldest person in the document is John Doe, a 55 year old lumberjack living in Newfoundland."
+
+Thought: I will now generate an image showcasing the oldest person.
+Code:
+```py
+image = image_generator("A portrait of John Doe, a 55-year-old man living in Canada.")
+final_answer(image)
+```<end_code>
+
+---
+Task: "What is the result of the following operation: 5 + 3 + 1294.678?"
+
+Thought: I will use python code to compute the result of the operation and then return the final answer using the `final_answer` tool
+Code:
+```py
+result = 5 + 3 + 1294.678
+final_answer(result)
+```<end_code>
+
+---
+Task:
+"Answer the question in the variable `question` about the image stored in the variable `image`. The question is in French.
+You have been provided with these additional arguments, that you can access using the keys as variables in your python code:
+{'question': 'Quel est l'animal sur l'image?', 'image': 'path/to/image.jpg'}"
+
+Thought: I will use the following tools: `translator` to translate the question into English and then `image_qa` to answer the question on the input image.
+Code:
+```py
+translated_question = translator(question=question, src_lang="French", tgt_lang="English")
+print(f"The translated question is {translated_question}.")
+answer = image_qa(image=image, question=translated_question)
+final_answer(f"The answer is {answer}")
+```<end_code>
+
+---
+Task:
+In a 1979 interview, Stanislaus Ulam discusses with Martin Sherwin about other great physicists of his time, including Oppenheimer.
+What does he say was the consequence of Einstein learning too much math on his creativity, in one word?
+
+Thought: I need to find and read the 1979 interview of Stanislaus Ulam with Martin Sherwin.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein")
+print(pages)
+```<end_code>
+Observation:
+No result found for query "1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein".
+
+Thought: The query was maybe too restrictive and did not find any results. Let's try again with a broader query.
+Code:
+```py
+pages = search(query="1979 interview Stanislaus Ulam")
+print(pages)
+```<end_code>
+Observation:
+Found 6 pages:
+[Stanislaus Ulam 1979 interview](https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/)
+
+[Ulam discusses Manhattan Project](https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/)
+
+(truncated)
+
+Thought: I will read the first 2 pages to know more.
+Code:
+```py
+for url in ["https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/", "https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/"]:
+    whole_page = visit_webpage(url)
+    print(whole_page)
+    print("\n" + "="*80 + "\n")  # Print separator between pages
+```<end_code>
+Observation:
+Manhattan Project Locations:
+Los Alamos, NM
+Stanislaus Ulam was a Polish-American mathematician. He worked on the Manhattan Project at Los Alamos and later helped design the hydrogen bomb. In this interview, he discusses his work at
+(truncated)
+
+Thought: I now have the final answer: from the webpages visited, Stanislaus Ulam says of Einstein: "He learned too much mathematics and sort of diminished, it seems to me personally, it seems to me his purely physics creativity." Let's answer in one word.
+Code:
+```py
+final_answer("diminished")
+```<end_code>
+
+---
+Task: "Which city has the highest population: Guangzhou or Shanghai?"
 
-{{tool_descriptions}}
+Thought: I need to get the populations for both cities and compare them: I will use the tool `search` to get the population of both cities.
+Code:
+```py
+for city in ["Guangzhou", "Shanghai"]:
+    print(f"Population {city}:", search(f"{city} population")
+```<end_code>
+Observation:
+Population Guangzhou: ['Guangzhou has a population of 15 million inhabitants as of 2021.']
+Population Shanghai: '26 million (2019)'
+
+Thought: Now I know that Shanghai has the highest population.
+Code:
+```py
+final_answer("Shanghai")
+```<end_code>
+
+---
+Task: "What is the current age of the pope, raised to the power 0.36?"
 
-{{managed_agents_descriptions}}
+Thought: I will use the tool `wiki` to get the age of the pope, and confirm that with a web search.
+Code:
+```py
+pope_age_wiki = wiki(query="current pope age")
+print("Pope age as per wikipedia:", pope_age_wiki)
+pope_age_search = web_search(query="current pope age")
+print("Pope age as per google search:", pope_age_search)
+```<end_code>
+Observation:
+Pope age: "The pope Francis is currently 88 years old."
+
+Thought: I know that the pope is 88 years old. Let's compute the result using python code.
+Code:
+```py
+pope_current_age = 88 ** 0.36
+final_answer(pope_current_age)
+```<end_code>
+
+Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools:
+{%- for tool in tools.values() %}
+- {{ tool.name }}: {{ tool.description }}
+    Takes inputs: {{tool.inputs}}
+    Returns an output of type: {{tool.output_type}}
+{%- endfor %}
+
+{%- if managed_agents and managed_agents.values() | list %}
+You can also give tasks to team members.
+Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+Given that this team member is a real human, you should be very verbose in your task.
+Here is a list of the team members that you can call:
+{%- for agent in managed_agents.values() %}
+- {{ agent.name }}: {{ agent.description }}
+{%- endfor %}
+{%- endif %}
 
 Here are the rules you should always follow to solve your task:
 1. Always provide a 'Thought:' sequence, and a 'Code:\n```py' sequence ending with '```<end_code>' sequence, else you will fail.
@@ -224,7 +363,7 @@ Here are the rules you should always follow to solve your task:
 4. Take care to not chain too many sequential tool calls in the same code block, especially when the output format is unpredictable. For instance, a call to search has an unpredictable return format, so do not have another tool call that depends on its output in the same block: rather output results with print() to use them in the next block.
 5. Call a tool only when needed, and never re-do a tool call that you previously did with the exact same parameters.
 6. Don't name any new variable with the same name as a tool: for instance don't name a variable 'final_answer'.
-7. Never create any notional variables in our code, as having these in your logs might derail you from the true variables.
+7. Never create any notional variables in our code, as having these in your logs will derail you from the true variables.
 8. You can use imports in your code, but only from the following list of modules: {{authorized_imports}}
 9. The state persists between code executions: so if in one step you've created variables or imported modules, these will all persist.
 10. Don't give up! You're in charge of solving the task, not providing directions to solve it.
@@ -232,11 +371,29 @@ Here are the rules you should always follow to solve your task:
 Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
 ```
 
-如你所见，有一些占位符，如 `"{{tool_descriptions}}"`：这些将在 agent 初始化时用于插入某些自动生成的工具或管理 agent 的描述。
+如你所见，有一些占位符，如 `"{{ tool.description }}"`：这些将在 agent 初始化时用于插入某些自动生成的工具或管理 agent 的描述。
 
 因此，虽然你可以通过将自定义提示作为参数传递给 `system_prompt` 参数来覆盖此系统提示模板，但你的新系统提示必须包含以下占位符：
-- `"{{tool_descriptions}}"` 用于插入工具描述。
-- `"{{managed_agents_description}}"` 用于插入 managed agent 的描述（如果有）。
+- 用于插入工具描述。
+  ```
+  {%- for tool in tools.values() %}
+  - {{ tool.name }}: {{ tool.description }}
+      Takes inputs: {{tool.inputs}}
+      Returns an output of type: {{tool.output_type}}
+  {%- endfor %}
+  ```
+- 用于插入 managed agent 的描述（如果有）。
+  ```
+  {%- if managed_agents and managed_agents.values() | list %}
+  You can also give tasks to team members.
+  Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
+  Given that this team member is a real human, you should be very verbose in your task.
+  Here is a list of the team members that you can call:
+  {%- for agent in managed_agents.values() %}
+  - {{ agent.name }}: {{ agent.description }}
+  {%- endfor %}
+  {%- endif %}
+  ```
 - 仅限 `CodeAgent`：`"{{authorized_imports}}"` 用于插入授权导入列表。
 
 然后你可以根据如下，更改系统提示：
diff --git a/pyproject.toml b/pyproject.toml
index 820d5d4..b7bc6a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ litellm = [
   "litellm>=1.60.2",
 ]
 mcp = [
-  "mcpadapt>=0.0.6",
+  "mcpadapt>=0.0.15",
   "mcp",
 ]
 mlx-lm = [
@@ -67,8 +67,12 @@ transformers = [
   "transformers>=4.0.0,<4.49.0",
   "smolagents[torch]",
 ]
+vision = [
+  "helium",
+  "selenium",
+]
 all = [
-  "smolagents[audio,docker,e2b,gradio,litellm,mcp,openai,telemetry,transformers]",
+  "smolagents[audio,docker,e2b,gradio,litellm,mcp,openai,telemetry,transformers,vision]",
 ]
 quality = [
   "ruff>=0.9.0",
diff --git a/src/smolagents/cli.py b/src/smolagents/cli.py
index 7d93a3b..83f1745 100644
--- a/src/smolagents/cli.py
+++ b/src/smolagents/cli.py
@@ -26,8 +26,8 @@ from smolagents.default_tools import TOOL_MAPPING
 leopard_prompt = "How many seconds would it take for a leopard at full speed to run through Pont des Arts?"
 
 
-def parse_arguments(description):
-    parser = argparse.ArgumentParser(description=description)
+def parse_arguments():
+    parser = argparse.ArgumentParser(description="Run a CodeAgent with all specified parameters")
     parser.add_argument(
         "prompt",
         type=str,
@@ -103,15 +103,21 @@ def load_model(model_type: str, model_id: str, api_base: str | None = None, api_
         raise ValueError(f"Unsupported model type: {model_type}")
 
 
-def main():
+def main(
+    prompt: str,
+    tools: list[str],
+    model_type: str,
+    model_id: str,
+    api_base: str | None = None,
+    api_key: str | None = None,
+    imports: list[str] | None = None,
+) -> None:
     load_dotenv()
 
-    args = parse_arguments(description="Run a CodeAgent with all specified parameters")
-
-    model = load_model(args.model_type, args.model_id, args.api_base, args.api_key)
+    model = load_model(model_type, model_id, api_base=api_base, api_key=api_key)
 
     available_tools = []
-    for tool_name in args.tools:
+    for tool_name in tools:
         if "/" in tool_name:
             available_tools.append(Tool.from_space(tool_name))
         else:
@@ -120,11 +126,21 @@ def main():
             else:
                 raise ValueError(f"Tool {tool_name} is not recognized either as a default tool or a Space.")
 
-    print(f"Running agent with these tools: {args.tools}")
-    agent = CodeAgent(tools=available_tools, model=model, additional_authorized_imports=args.imports)
+    print(f"Running agent with these tools: {tools}")
+    agent = CodeAgent(tools=available_tools, model=model, additional_authorized_imports=imports)
 
-    agent.run(args.prompt)
+    agent.run(prompt)
 
 
 if __name__ == "__main__":
-    main()
+    args = parse_arguments()
+
+    main(
+        args.prompt,
+        args.tools,
+        args.model_type,
+        args.model_id,
+        api_base=args.api_base,
+        api_key=args.api_key,
+        imports=args.imports,
+    )
diff --git a/src/smolagents/local_python_executor.py b/src/smolagents/local_python_executor.py
index ebb8a0a..0dc3c1d 100644
--- a/src/smolagents/local_python_executor.py
+++ b/src/smolagents/local_python_executor.py
@@ -24,7 +24,7 @@ import re
 from collections.abc import Mapping
 from functools import wraps
 from importlib import import_module
-from types import ModuleType
+from types import BuiltinFunctionType, FunctionType, ModuleType
 from typing import Any, Callable, Dict, List, Optional, Set, Tuple
 
 import numpy as np
@@ -116,25 +116,15 @@ BASE_PYTHON_TOOLS = {
     "complex": complex,
 }
 
-DANGEROUS_PATTERNS = (
-    "_os",
-    "os",
-    "subprocess",
-    "_subprocess",
-    "pty",
-    "system",
-    "popen",
-    "spawn",
-    "shutil",
-    "sys",
-    "pathlib",
-    "io",
-    "socket",
-    "compile",
-    "eval",
-    "exec",
-    "multiprocessing",
-)
+DANGEROUS_FUNCTIONS = [
+    "builtins.compile",
+    "builtins.eval",
+    "builtins.exec",
+    "builtins.globalsbuiltins.locals",
+    "builtins.__import__",
+    "os.popen",
+    "os.system",
+]
 
 DANGEROUS_MODULES = [
     "builtins",
@@ -248,23 +238,32 @@ def safer_eval(func: Callable):
         result = func(expression, state, static_tools, custom_tools, authorized_imports=authorized_imports)
         if "*" not in authorized_imports:
             if isinstance(result, ModuleType):
-                for module in DANGEROUS_MODULES:
+                for module_name in DANGEROUS_MODULES:
                     if (
-                        module not in authorized_imports
-                        and result.__name__ == module
+                        module_name not in authorized_imports
+                        and result.__name__ == module_name
                         # builtins has no __file__ attribute
-                        and getattr(result, "__file__", "") == getattr(import_module(module), "__file__", "")
+                        and getattr(result, "__file__", "") == getattr(import_module(module_name), "__file__", "")
                     ):
-                        raise InterpreterError(f"Forbidden return value: {module}")
+                        raise InterpreterError(f"Forbidden access to module: {module_name}")
             elif isinstance(result, dict) and result.get("__name__"):
-                for module in DANGEROUS_MODULES:
+                for module_name in DANGEROUS_MODULES:
                     if (
-                        module not in authorized_imports
-                        and result["__name__"] == module
+                        module_name not in authorized_imports
+                        and result["__name__"] == module_name
                         # builtins has no __file__ attribute
-                        and result.get("__file__", "") == getattr(import_module(module), "__file__", "")
+                        and result.get("__file__", "") == getattr(import_module(module_name), "__file__", "")
+                    ):
+                        raise InterpreterError(f"Forbidden access to module: {module_name}")
+            elif isinstance(result, (FunctionType, BuiltinFunctionType)):
+                for qualified_function_name in DANGEROUS_FUNCTIONS:
+                    module_name, function_name = qualified_function_name.rsplit(".", 1)
+                    if (
+                        function_name not in static_tools
+                        and result.__name__ == function_name
+                        and result.__module__ == module_name
                     ):
-                        raise InterpreterError(f"Forbidden return value: {module}")
+                        raise InterpreterError(f"Forbidden access to function: {function_name}")
         return result
 
     return _check_return
@@ -1081,15 +1080,6 @@ def get_safe_module(raw_module, authorized_imports, visited=None):
 
     # Copy all attributes by reference, recursively checking modules
     for attr_name in dir(raw_module):
-        # Skip dangerous patterns at any level
-        if any(
-            pattern in raw_module.__name__.split(".") + [attr_name]
-            and not check_module_authorized(pattern, authorized_imports)
-            for pattern in DANGEROUS_PATTERNS
-        ):
-            logger.info(f"Skipping dangerous attribute {raw_module.__name__}.{attr_name}")
-            continue
-
         try:
             attr_value = getattr(raw_module, attr_name)
         except (ImportError, AttributeError) as e:
@@ -1112,8 +1102,6 @@ def check_module_authorized(module_name, authorized_imports):
         return True
     else:
         module_path = module_name.split(".")
-        if any([module in DANGEROUS_PATTERNS and module not in authorized_imports for module in module_path]):
-            return False
         # ["A", "B", "C"] -> ["A", "A.B", "A.B.C"]
         module_subpaths = [".".join(module_path[:i]) for i in range(1, len(module_path) + 1)]
         return any(subpath in authorized_imports for subpath in module_subpaths)
diff --git a/src/smolagents/prompts/code_agent.yaml b/src/smolagents/prompts/code_agent.yaml
index b7388e2..f98b459 100644
--- a/src/smolagents/prompts/code_agent.yaml
+++ b/src/smolagents/prompts/code_agent.yaml
@@ -156,7 +156,6 @@ system_prompt: |-
   {%- for agent in managed_agents.values() %}
   - {{ agent.name }}: {{ agent.description }}
   {%- endfor %}
-  {%- else %}
   {%- endif %}
 
   Here are the rules you should always follow to solve your task:
@@ -231,7 +230,6 @@ planning:
     {%- for agent in managed_agents.values() %}
     - {{ agent.name }}: {{ agent.description }}
     {%- endfor %}
-    {%- else %}
     {%- endif %}
 
     List of facts that you know:
@@ -290,7 +288,6 @@ planning:
     {%- for agent in managed_agents.values() %}
     - {{ agent.name }}: {{ agent.description }}
     {%- endfor %}
-    {%- else %}
     {%- endif %}
 
     Here is the up to date list of facts that you know:
diff --git a/src/smolagents/prompts/toolcalling_agent.yaml b/src/smolagents/prompts/toolcalling_agent.yaml
index 744bd74..6d37844 100644
--- a/src/smolagents/prompts/toolcalling_agent.yaml
+++ b/src/smolagents/prompts/toolcalling_agent.yaml
@@ -104,7 +104,6 @@ system_prompt: |-
   {%- for agent in managed_agents.values() %}
   - {{ agent.name }}: {{ agent.description }}
   {%- endfor %}
-  {%- else %}
   {%- endif %}
 
   Here are the rules you should always follow to solve your task:
@@ -174,7 +173,6 @@ planning:
     {%- for agent in managed_agents.values() %}
     - {{ agent.name }}: {{ agent.description }}
     {%- endfor %}
-    {%- else %}
     {%- endif %}
 
     List of facts that you know:
@@ -233,7 +231,6 @@ planning:
     {%- for agent in managed_agents.values() %}
     - {{ agent.name }}: {{ agent.description }}
     {%- endfor %}
-    {%- else %}
     {%- endif %}
 
     Here is the up to date list of facts that you know:
diff --git a/src/smolagents/vision_web_browser.py b/src/smolagents/vision_web_browser.py
index 29bcca0..d8216e4 100644
--- a/src/smolagents/vision_web_browser.py
+++ b/src/smolagents/vision_web_browser.py
@@ -187,15 +187,12 @@ When you have modals or cookie banners on screen, you should get rid of them bef
 """
 
 
-def main():
+def main(prompt: str, model_type: str, model_id: str) -> None:
     # Load environment variables
     load_dotenv()
 
-    # Parse command line arguments
-    args = parse_arguments()
-
     # Initialize the model based on the provided arguments
-    model = load_model(args.model_type, args.model_id)
+    model = load_model(model_type, model_id)
 
     global driver
     driver = initialize_driver()
@@ -203,8 +200,11 @@ def main():
 
     # Run the agent with the provided prompt
     agent.python_executor("from helium import *")
-    agent.run(args.prompt + helium_instructions)
+    agent.run(prompt + helium_instructions)
 
 
 if __name__ == "__main__":
-    main()
+    # Parse command line arguments
+    args = parse_arguments()
+
+    main(args.prompt, args.model_type, args.model_id)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 69832f0..eec13ed 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
 import pytest
 
 from smolagents.cli import load_model
+from smolagents.local_python_executor import LocalPythonExecutor
 from smolagents.models import HfApiModel, LiteLLMModel, OpenAIServerModel, TransformersModel
 
 
@@ -52,3 +53,56 @@ def test_load_model_hf_api_model(set_env_vars):
 def test_load_model_invalid_model_type():
     with pytest.raises(ValueError, match="Unsupported model type: InvalidModel"):
         load_model("InvalidModel", "test_model_id")
+
+
+def test_cli_main(capsys):
+    with patch("smolagents.cli.load_model") as mock_load_model:
+        mock_load_model.return_value = "mock_model"
+        with patch("smolagents.cli.CodeAgent") as mock_code_agent:
+            from smolagents.cli import main
+
+            main("test_prompt", [], "HfApiModel", "test_model_id")
+    # load_model
+    assert len(mock_load_model.call_args_list) == 1
+    assert mock_load_model.call_args.args == ("HfApiModel", "test_model_id")
+    assert mock_load_model.call_args.kwargs == {"api_base": None, "api_key": None}
+    # CodeAgent
+    assert len(mock_code_agent.call_args_list) == 1
+    assert mock_code_agent.call_args.args == ()
+    assert mock_code_agent.call_args.kwargs == {
+        "tools": [],
+        "model": "mock_model",
+        "additional_authorized_imports": None,
+    }
+    # agent.run
+    assert len(mock_code_agent.return_value.run.call_args_list) == 1
+    assert mock_code_agent.return_value.run.call_args.args == ("test_prompt",)
+    # print
+    captured = capsys.readouterr()
+    assert "Running agent with these tools: []" in captured.out
+
+
+def test_vision_web_browser_main():
+    with patch("smolagents.vision_web_browser.helium"):
+        with patch("smolagents.vision_web_browser.load_model") as mock_load_model:
+            mock_load_model.return_value = "mock_model"
+            with patch("smolagents.vision_web_browser.CodeAgent") as mock_code_agent:
+                from smolagents.vision_web_browser import helium_instructions, main
+
+                main("test_prompt", "HfApiModel", "test_model_id")
+    # load_model
+    assert len(mock_load_model.call_args_list) == 1
+    assert mock_load_model.call_args.args == ("HfApiModel", "test_model_id")
+    # CodeAgent
+    assert len(mock_code_agent.call_args_list) == 1
+    assert mock_code_agent.call_args.args == ()
+    assert len(mock_code_agent.call_args.kwargs["tools"]) == 4
+    assert mock_code_agent.call_args.kwargs["model"] == "mock_model"
+    assert mock_code_agent.call_args.kwargs["additional_authorized_imports"] == ["helium"]
+    # agent.python_executor
+    assert len(mock_code_agent.return_value.python_executor.call_args_list) == 1
+    assert mock_code_agent.return_value.python_executor.call_args.args == ("from helium import *",)
+    assert LocalPythonExecutor(["helium"])("from helium import *") == (None, "", False)
+    # agent.run
+    assert len(mock_code_agent.return_value.run.call_args_list) == 1
+    assert mock_code_agent.return_value.run.call_args.args == ("test_prompt" + helium_instructions,)
diff --git a/tests/test_local_python_executor.py b/tests/test_local_python_executor.py
index bb071b1..a34ac29 100644
--- a/tests/test_local_python_executor.py
+++ b/tests/test_local_python_executor.py
@@ -944,22 +944,6 @@ shift_intervals
     Got:      {result}
     """
 
-    def test_dangerous_subpackage_access_blocked(self):
-        # Direct imports with dangerous patterns should fail
-        code = "import random._os"
-        with pytest.raises(InterpreterError):
-            evaluate_python_code(code)
-
-        # Import of whitelisted modules should succeed but dangerous submodules should not exist
-        code = "import random;random._os.system('echo bad command passed')"
-        with pytest.raises(InterpreterError) as e:
-            evaluate_python_code(code)
-        assert "AttributeError: module 'random' has no attribute '_os'" in str(e)
-
-        code = "import doctest;doctest.inspect.os.system('echo bad command passed')"
-        with pytest.raises(InterpreterError):
-            evaluate_python_code(code, authorized_imports=["doctest"])
-
     def test_close_matches_subscript(self):
         code = 'capitals = {"Czech Republic": "Prague", "Monaco": "Monaco", "Bhutan": "Thimphu"};capitals["Butan"]'
         with pytest.raises(Exception) as e:
@@ -1400,7 +1384,7 @@ class TestPrintContainer:
         ("AnyModule", ["*"], True),
         ("os", ["os"], True),
         ("AnyModule", ["AnyModule"], True),
-        ("Module.os", ["Module"], False),
+        ("Module.os", ["Module"], True),
         ("Module.os", ["Module", "os"], True),
         ("os.path", ["os"], True),
         ("os", ["os.path"], False),
@@ -1489,6 +1473,63 @@ class TestLocalPythonExecutorSecurity:
         ):
             executor("import builtins")
 
+    @pytest.mark.parametrize(
+        "additional_authorized_imports, expected_error",
+        [([], InterpreterError("Import of builtins is not allowed")), (["builtins"], None)],
+    )
+    def test_vulnerability_builtins_safe_functions(self, additional_authorized_imports, expected_error):
+        executor = LocalPythonExecutor(additional_authorized_imports)
+        with (
+            pytest.raises(type(expected_error), match=f".*{expected_error}")
+            if isinstance(expected_error, Exception)
+            else does_not_raise()
+        ):
+            executor("import builtins; builtins.print(1)")
+
+    @pytest.mark.parametrize(
+        "additional_authorized_imports, additional_tools, expected_error",
+        [
+            ([], [], InterpreterError("Import of builtins is not allowed")),
+            (["builtins"], [], InterpreterError("Forbidden access to function: exec")),
+            (["builtins"], ["exec"], None),
+        ],
+    )
+    def test_vulnerability_builtins_dangerous_functions(
+        self, additional_authorized_imports, additional_tools, expected_error
+    ):
+        executor = LocalPythonExecutor(additional_authorized_imports)
+        if additional_tools:
+            from builtins import exec
+
+            executor.send_tools({"exec": exec})
+        with (
+            pytest.raises(type(expected_error), match=f".*{expected_error}")
+            if isinstance(expected_error, Exception)
+            else does_not_raise()
+        ):
+            executor("import builtins; builtins.exec")
+
+    @pytest.mark.parametrize(
+        "additional_authorized_imports, additional_tools, expected_error",
+        [
+            ([], [], InterpreterError("Import of os is not allowed")),
+            (["os"], [], InterpreterError("Forbidden access to function: popen")),
+            (["os"], ["popen"], None),
+        ],
+    )
+    def test_vulnerability_dangerous_functions(self, additional_authorized_imports, additional_tools, expected_error):
+        executor = LocalPythonExecutor(additional_authorized_imports)
+        if additional_tools:
+            from os import popen
+
+            executor.send_tools({"popen": popen})
+        with (
+            pytest.raises(type(expected_error), match=f".*{expected_error}")
+            if isinstance(expected_error, Exception)
+            else does_not_raise()
+        ):
+            executor("import os; os.popen")
+
     @pytest.mark.parametrize(
         "additional_authorized_imports, expected_error",
         [([], InterpreterError("Import of sys is not allowed")), (["os", "sys"], None)],
@@ -1511,7 +1552,7 @@ class TestLocalPythonExecutorSecurity:
 
     @pytest.mark.parametrize(
         "additional_authorized_imports, expected_error",
-        [(["importlib"], InterpreterError("Forbidden return value: os")), (["importlib", "os"], None)],
+        [(["importlib"], InterpreterError("Forbidden access to module: os")), (["importlib", "os"], None)],
     )
     def test_vulnerability_via_importlib(self, additional_authorized_imports, expected_error):
         executor = LocalPythonExecutor(additional_authorized_imports)
@@ -1529,19 +1570,58 @@ class TestLocalPythonExecutorSecurity:
                 )
             )
 
+    @pytest.mark.parametrize(
+        "code, additional_authorized_imports, expected_error",
+        [
+            # os submodule
+            ("import queue; queue.threading._os.system(':')", [], InterpreterError("Forbidden access to module: os")),
+            ("import random; random._os.system(':')", [], InterpreterError("Forbidden access to module: os")),
+            (
+                "import random; random.__dict__['_os'].system(':')",
+                [],
+                InterpreterError("Forbidden access to module: os"),
+            ),
+            (
+                "import doctest; doctest.inspect.os.system(':')",
+                ["doctest"],
+                InterpreterError("Forbidden access to module: os"),
+            ),
+            # subprocess submodule
+            (
+                "import asyncio; asyncio.base_events.events.subprocess",
+                ["asyncio"],
+                InterpreterError("Forbidden access to module: subprocess"),
+            ),
+            # sys submodule
+            (
+                "import queue; queue.threading._sys.modules['os'].system(':')",
+                [],
+                InterpreterError("Forbidden access to module: sys"),
+            ),
+            # Allowed
+            ("import pandas; pandas.io", ["pandas"], None),
+        ],
+    )
+    def test_vulnerability_via_submodules(self, code, additional_authorized_imports, expected_error):
+        executor = LocalPythonExecutor(additional_authorized_imports)
+        with (
+            pytest.raises(type(expected_error), match=f".*{expected_error}")
+            if isinstance(expected_error, Exception)
+            else does_not_raise()
+        ):
+            executor(code)
+
     @pytest.mark.parametrize(
         "additional_authorized_imports, additional_tools, expected_error",
         [
             ([], [], InterpreterError("Import of sys is not allowed")),
-            (["sys"], [], InterpreterError("Forbidden return value: builtins")),
+            (["sys"], [], InterpreterError("Forbidden access to module: builtins")),
             (
                 ["sys", "builtins"],
                 [],
-                InterpreterError(
-                    "Invoking a builtin function that has not been explicitly added as a tool is not allowed"
-                ),
+                InterpreterError("Forbidden access to function: __import__"),
             ),
-            (["sys", "builtins"], ["__import__"], InterpreterError("Forbidden return value: os")),
+            (["sys", "builtins"], ["__import__"], InterpreterError("Forbidden access to module: os")),
             (["sys", "builtins", "os"], ["__import__"], None),
         ],
     )
@@ -1571,7 +1651,10 @@ class TestLocalPythonExecutorSecurity:
     @pytest.mark.parametrize("patch_builtin_import_module", [False, True])  # builtins_import.__module__ = None
     @pytest.mark.parametrize(
         "additional_authorized_imports, additional_tools, expected_error",
-        [([], [], InterpreterError("Forbidden return value: builtins")), (["builtins", "os"], ["__import__"], None)],
+        [
+            ([], [], InterpreterError("Forbidden access to module: builtins")),
+            (["builtins", "os"], ["__import__"], None),
+        ],
     )
     def test_vulnerability_builtins_via_traceback(
         self, patch_builtin_import_module, additional_authorized_imports, additional_tools, expected_error, monkeypatch
@@ -1605,7 +1688,10 @@ class TestLocalPythonExecutorSecurity:
     @pytest.mark.parametrize("patch_builtin_import_module", [False, True])  # builtins_import.__module__ = None
     @pytest.mark.parametrize(
         "additional_authorized_imports, additional_tools, expected_error",
-        [([], [], InterpreterError("Forbidden return value: builtins")), (["builtins", "os"], ["__import__"], None)],
+        [
+            ([], [], InterpreterError("Forbidden access to module: builtins")),
+            (["builtins", "os"], ["__import__"], None),
+        ],
     )
     def test_vulnerability_builtins_via_class_catch_warnings(
         self, patch_builtin_import_module, additional_authorized_imports, additional_tools, expected_error, monkeypatch
@@ -1640,7 +1726,7 @@ class TestLocalPythonExecutorSecurity:
     @pytest.mark.filterwarnings("ignore::DeprecationWarning")
     @pytest.mark.parametrize(
         "additional_authorized_imports, expected_error",
-        [([], InterpreterError("Forbidden return value: os")), (["os"], None)],
+        [([], InterpreterError("Forbidden access to module: os")), (["os"], None)],
     )
     def test_vulnerability_load_module_via_builtin_importer(self, additional_authorized_imports, expected_error):
         executor = LocalPythonExecutor(additional_authorized_imports)