Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ directly without this prefix. This is essential for proper dependency management
and environment isolation.

**Examples**:

- ✅ `pixi run test` (correct)
- ❌ `pytest` (incorrect - will fail)
- ✅ `pixi run pytest tests/specific_test.py` (correct)
Expand Down Expand Up @@ -63,6 +64,55 @@ the check command.
All examples and notebooks should be created as Marimo notebooks (`.py` files).
If you encounter any `.ipynb` files, they should be converted to Marimo format.

**Notebook Dependencies**: All notebooks should install llamabot locally using the
`[tool.uv.sources]` section to ensure they use the latest development version:

```python
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "llamabot==0.13.11",
# ]
#
# [tool.uv.sources]
# llamabot = { path = "../", editable = true }
# ///
```

This pattern ensures notebooks use the local development version of llamabot
instead of the published version, allowing them to test new features and changes.

**FastMCP Usage**: When creating MCP clients from FastMCP servers, pass the FastMCP
object directly to the Client constructor. Do NOT call `get_server()` method:

```python
# ✅ Correct
server = FastMCP('my-server')
client = Client(server)

# ❌ Incorrect - FastMCP objects don't have get_server() method
client = Client(server.get_server()) # AttributeError
```

**Notebook Async Issues**: When using async code in Jupyter notebooks, avoid
`asyncio.run()` as it conflicts with the existing event loop. Use `nest_asyncio`
instead:

```python
# ✅ Correct for notebooks
import nest_asyncio
nest_asyncio.apply()
loop = asyncio.get_running_loop()
result = loop.run_until_complete(async_function())

# ❌ Incorrect - will fail in notebooks
result = asyncio.run(async_function()) # RuntimeError
```

The codebase includes helper functions (`run_async_in_sync` in AgentBot,
`_run_async_in_sync` in ToolBot) that automatically handle this for both regular
Python and notebook environments.

**Markdown Linting**: Always run `markdownlint` on any markdown file that you edit.
Use `markdownlint filename.md` to check for issues and fix them before committing.

Expand Down Expand Up @@ -162,16 +212,23 @@ The CLI is built with Typer and organized in `llamabot/cli/`:
### Packaging

- **Build Backend**: Uses Hatchling for wheel and source distribution builds
- **Hatchling respects .gitignore**: By default, Hatchling excludes files listed in `.gitignore` from package distribution
- **Including ignored files**: To include files that are in `.gitignore` (like build artifacts or generated data), use the `artifacts` configuration in `pyproject.toml`:
- **Hatchling respects .gitignore**: By default, Hatchling excludes files listed
in `.gitignore` from package distribution
- **Including ignored files**: To include files that are in `.gitignore` (like build
artifacts or generated data), use the `artifacts` configuration in `pyproject.toml`:

```toml
[tool.hatch.build.targets.wheel]
artifacts = [
"path/to/files/**/*",
]
```
- **MCP Database**: The `llamabot/data/mcp_docs/` directory is built during CI/CD and included in the package using the `artifacts` configuration, even though it's in `.gitignore`
- **CI/CD Workflow**: Database is built first, then the package is built once to include the database files

- **MCP Database**: The `llamabot/data/mcp_docs/` directory is built during CI/CD
and included in the package using the `artifacts` configuration, even though it's
in `.gitignore`
- **CI/CD Workflow**: Database is built first, then the package is built once to
include the database files

## Key Dependencies

Expand Down
170 changes: 112 additions & 58 deletions llamabot/bot/agentbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
and in what order, making it suitable for complex, multi-step tasks.
"""

import asyncio
import hashlib
import json
from concurrent.futures import ThreadPoolExecutor, as_completed

# Note: ThreadPoolExecutor and as_completed are no longer used with MCP approach
from datetime import datetime
from typing import Any, Callable, List, Optional, Union

from fastmcp import Client
from loguru import logger

from llamabot.bot.simplebot import (
Expand All @@ -20,6 +23,7 @@
make_response,
stream_chunks,
)
from llamabot.components.local_mcp_server import LocalMCPServer
from llamabot.components.messages import (
AIMessage,
BaseMessage,
Expand All @@ -33,6 +37,21 @@
from llamabot.bot.toolbot import ToolBot, toolbot_sysprompt


def run_async_in_sync(coro):
"""Run async coroutine in a sync context, handling both regular and notebook environments."""
try:
# Try to get the current event loop
loop = asyncio.get_running_loop()
# If we're in a running loop (like Jupyter), use nest_asyncio
import nest_asyncio

nest_asyncio.apply()
return loop.run_until_complete(coro)
except RuntimeError:
# No event loop running, safe to use asyncio.run()
return asyncio.run(coro)


def hash_result(result: Any) -> str:
"""Generate a SHA256 hash for a result value.

Expand Down Expand Up @@ -111,6 +130,7 @@ def __init__(
model_name=default_language_model(),
stream_target: str = "none",
tools: Optional[list[Callable]] = None,
mcp_servers: Optional[List[str]] = None,
toolbot: Optional[ToolBot] = None,
**completion_kwargs,
):
Expand All @@ -122,18 +142,36 @@ def __init__(
**completion_kwargs,
)

# Create local MCP server for local tools
local_server = LocalMCPServer("local")
all_tools = [today_date, respond_to_user]
if tools is not None:
all_tools.extend([f for f in tools])
local_server.register_tools(all_tools)

# Create MCP clients
mcp_clients = []

# Add local client
local_client = Client(local_server.get_server())
mcp_clients.append(local_client)

# Add remote clients
if mcp_servers:
for server_url in mcp_servers:
remote_client = Client(server_url)
mcp_clients.append(remote_client)

# Keep legacy tool schemas for backward compatibility
self.tools = [f.json_schema for f in all_tools]
self.name_to_tool_map = {f.__name__: f for f in all_tools}

# Initialize ToolBot for tool selection
# Initialize ToolBot with MCP clients
if toolbot is None:
self.toolbot = ToolBot(
system_prompt=toolbot_sysprompt(globals_dict={}),
model_name=model_name,
tools=all_tools,
mcp_clients=mcp_clients,
**completion_kwargs,
)
else:
Expand Down Expand Up @@ -206,6 +244,30 @@ def __call__(
tool_calls = self.toolbot(*message_list)
logger.debug("ToolBot selected: {}", tool_calls)

# Check if agent provided a final answer without using tools
if not tool_calls and thought_content and len(thought_content.strip()) > 0:
# Check if the thought content looks like a final answer
if any(
phrase in thought_content.lower()
for phrase in [
"equals",
"is",
"the answer is",
"the result is",
"final answer",
"answer:",
"result:",
]
):
logger.debug("Agent provided final answer without tools")
final_message = AIMessage(content=thought_content)
self.run_meta["end_time"] = datetime.now()
self.run_meta["duration"] = (
self.run_meta["end_time"] - self.run_meta["start_time"]
).total_seconds()
sqlite_log(self, message_list)
return final_message

if tool_calls:
# Check for respond_to_user (final answer)
respond_to_user_calls = [
Expand All @@ -216,8 +278,8 @@ def __call__(
if respond_to_user_calls:
logger.debug("Found respond_to_user, executing final answer")
start_time = datetime.now()
result = execute_tool_call(
respond_to_user_calls[0], self.name_to_tool_map
result = run_async_in_sync(
self.toolbot.execute_tool_call(respond_to_user_calls[0])
)
duration = (datetime.now() - start_time).total_seconds()

Expand All @@ -243,64 +305,56 @@ def __call__(
sqlite_log(self, message_list + [final_message])
return final_message

# OBSERVATION PHASE: Execute tools and observe results
# OBSERVATION PHASE: Execute tools via MCP and observe results
logger.debug(
"Executing tools: {}", [call.function.name for call in tool_calls]
)
results = []

with ThreadPoolExecutor() as executor:
futures = {
executor.submit(
execute_tool_call, call, self.name_to_tool_map
): call
for call in tool_calls
}

for future in as_completed(futures):
call = futures[future]
start_time = datetime.now()
try:
result = future.result()
duration = (datetime.now() - start_time).total_seconds()

# Record successful tool usage
tool_name = call.function.name
if tool_name not in self.run_meta["tool_usage"]:
self.run_meta["tool_usage"][tool_name] = {
"calls": 0,
"success": 0,
"failures": 0,
"total_duration": 0.0,
}
self.run_meta["tool_usage"][tool_name]["calls"] += 1
self.run_meta["tool_usage"][tool_name]["success"] += 1
self.run_meta["tool_usage"][tool_name][
"total_duration"
] += duration

except Exception as e:
duration = (datetime.now() - start_time).total_seconds()

# Record failed tool usage
tool_name = call.function.name
if tool_name not in self.run_meta["tool_usage"]:
self.run_meta["tool_usage"][tool_name] = {
"calls": 0,
"success": 0,
"failures": 0,
"total_duration": 0.0,
}
self.run_meta["tool_usage"][tool_name]["calls"] += 1
self.run_meta["tool_usage"][tool_name]["failures"] += 1
self.run_meta["tool_usage"][tool_name][
"total_duration"
] += duration

result = f"Error: {str(e)}"

logger.debug("Tool result: {}", result)
results.append(result)
# Execute tools via ToolBot's MCP execution
for call in tool_calls:
start_time = datetime.now()
try:
result = run_async_in_sync(self.toolbot.execute_tool_call(call))
duration = (datetime.now() - start_time).total_seconds()

# Record successful tool usage
tool_name = call.function.name
if tool_name not in self.run_meta["tool_usage"]:
self.run_meta["tool_usage"][tool_name] = {
"calls": 0,
"success": 0,
"failures": 0,
"total_duration": 0.0,
}
self.run_meta["tool_usage"][tool_name]["calls"] += 1
self.run_meta["tool_usage"][tool_name]["success"] += 1
self.run_meta["tool_usage"][tool_name][
"total_duration"
] += duration

except Exception as e:
duration = (datetime.now() - start_time).total_seconds()

# Record failed tool usage
tool_name = call.function.name
if tool_name not in self.run_meta["tool_usage"]:
self.run_meta["tool_usage"][tool_name] = {
"calls": 0,
"success": 0,
"failures": 0,
"total_duration": 0.0,
}
self.run_meta["tool_usage"][tool_name]["calls"] += 1
self.run_meta["tool_usage"][tool_name]["failures"] += 1
self.run_meta["tool_usage"][tool_name][
"total_duration"
] += duration

result = f"Error: {str(e)}"

logger.debug("Tool result: {}", result)
results.append(result)

# Add observation to conversation
observation_content = (
Expand Down
Loading
Loading