Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ API_ADAPTER_PORT=8080
STREAM_TIMEOUT=120.0
HEARTBEAT_INTERVAL=15.0

# Agent Skills Configuration (optional, disabled by default)
SKILLS_ENABLED=false
SKILLS_DIR=./skills
SKILLS_EXEC_TIMEOUT=30

# Logging Configuration (optional)
LOG_LEVEL=INFO
LOG_FILE_PATH=./log/api_adapter.log
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,65 @@ If you think this is cool:
🐛 Open an issue if something’s broken.
🤝 Suggest a feature or submit a pull request!

This is early-stage but already usable in real-world demos.
This is early-stage but already usable in real-world demos.
Let’s build something powerful—together.

## Agent Skills

The server supports **agent skills** -- local script-based tools that the LLM can invoke during conversations. Skills follow the Claude Code skill directory standard. **This feature is off by default.**

### Enabling Skills

Set these environment variables (or add to `.env`):

```bash
SKILLS_ENABLED=true
SKILLS_DIR=/path/to/your/skills
SKILLS_EXEC_TIMEOUT=30 # optional, default 30 seconds
```

### Skill Directory Structure

Each skill lives in its own directory under `SKILLS_DIR`:

```
skills/
my-skill/
SKILL.md # Required: YAML frontmatter + instructions
scripts/ # Required: executable scripts (bash, python, etc.)
references/ # Optional: reference documentation
```

### SKILL.md Format

```yaml
---
name: my-skill
description: What this skill does and when to use it
---

## Instructions

Detailed usage instructions for the LLM.
```

### How It Works

1. At startup the server scans `SKILLS_DIR` and registers each skill as a tool
2. Skills are injected into LLM requests alongside MCP tools
3. When the LLM calls a skill tool, the server executes the specified script
4. Script output is returned as the tool result

Each skill becomes a tool named `skill__<name>` with parameters `script` (which script to run) and `args` (command-line arguments).

### Security

- **Off by default** -- must set `SKILLS_ENABLED=true` explicitly
- Scripts are confined to their skill’s `scripts/` directory (path traversal blocked)
- No shell execution (`shell=True` is never used)
- Only executable files can be run
- Configurable timeout prevents runaway scripts


## Star History

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies = [
"python-dotenv>=0.19.0",
"pydantic>=1.8.0",
"requests>=2.31.0",

"pyyaml>=6.0",
]
classifiers = [
"Programming Language :: Python :: 3",
Expand Down
60 changes: 58 additions & 2 deletions src/open_responses_server/api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from open_responses_server.common.config import logger, HEARTBEAT_INTERVAL, STREAM_TIMEOUT
from open_responses_server.common.llm_client import startup_llm_client, shutdown_llm_client, LLMClient
from open_responses_server.common.mcp_manager import mcp_manager
from open_responses_server.common.skill_manager import skill_manager
from open_responses_server.responses_service import convert_responses_to_chat_completions, process_chat_completions_stream
from open_responses_server.chat_completions_service import handle_chat_completions

Expand Down Expand Up @@ -75,13 +76,15 @@
"""Application startup event handler."""
await startup_llm_client()
await mcp_manager.startup_mcp_servers()
await skill_manager.startup_skills()
logger.info("API Controller startup complete.")

@app.on_event("shutdown")
async def shutdown_event():
"""Application shutdown event handler."""
await shutdown_llm_client()
await mcp_manager.shutdown_mcp_servers()
await skill_manager.shutdown_skills()
logger.info("API Controller shutdown complete.")


Expand Down Expand Up @@ -156,7 +159,22 @@
logger.info(f"[TOOL-INJECT] /responses: final tool count: {len(request_data['tools'])}")
else:
logger.info("[TOOL-INJECT] /responses: no MCP tools available in cache")


# Inject cached skill tools into request_data
if skill_manager.skill_functions_cache:
existing_tools = request_data.get("tools", [])
skill_tools = [
{"type": "function", "name": f["name"], "description": f.get("description"), "parameters": f.get("parameters", {})}
for f in skill_manager.skill_functions_cache
]
existing_tool_names = set(tool["name"] for tool in existing_tools if "name" in tool)

Check warning on line 170 in src/open_responses_server/api_controller.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace set constructor call with a set comprehension.

See more on https://sonarcloud.io/project/issues?id=teabranch_open-responses-server&issues=AZ1DCZC-BjcZnSaxOWqe&open=AZ1DCZC-BjcZnSaxOWqe&pullRequest=53
filtered_skill_tools = [
tool for tool in skill_tools
if tool["name"] not in existing_tool_names
]
request_data["tools"] = existing_tools + filtered_skill_tools
logger.info(f"[TOOL-INJECT] /responses: injected {len(filtered_skill_tools)} skill tools")

# Convert request to chat.completions format
chat_request = convert_responses_to_chat_completions(request_data)

Expand Down Expand Up @@ -204,7 +222,27 @@
logger.info(f"[TOOL-CONVERT] /responses: final chat_request tools count: {len(chat_request.get('tools', []))}")
else:
logger.info("[TOOL-CONVERT] /responses: no MCP functions cached, sending without MCP tools")


# Inject skill tools into chat_request
if skill_manager.skill_functions_cache:
if "tools" not in chat_request:
chat_request["tools"] = []
existing_tool_names_cr = set()
for tool in chat_request["tools"]:
if isinstance(tool, dict) and "function" in tool and "name" in tool["function"]:
existing_tool_names_cr.add(tool["function"]["name"])
elif isinstance(tool, dict) and "name" in tool:
existing_tool_names_cr.add(tool["name"])
skill_tools_added = []
for func in skill_manager.skill_functions_cache:
if func.get("name") not in existing_tool_names_cr:
chat_request["tools"].append({
"type": "function",
"function": func
})
skill_tools_added.append(func.get("name"))
logger.info(f"[TOOL-CONVERT] /responses: added {len(skill_tools_added)} skill tools: {skill_tools_added}")

# Remove tool_choice when no functions/tools are provided
if not chat_request.get("functions") and not chat_request.get("tools"):
chat_request.pop("tool_choice", None)
Expand Down Expand Up @@ -289,6 +327,24 @@
# If we don't have any tools either, remove that key
chat_request.pop("tools", None)
logger.info("No tools or functions available, sending without them")
# Inject skill tools into streaming chat_request
if skill_manager.skill_functions_cache:
stream_tools = chat_request.get("tools", [])
stream_tool_names = set()
for tool in stream_tools:
if isinstance(tool, dict) and "function" in tool and "name" in tool["function"]:
stream_tool_names.add(tool["function"]["name"])
elif isinstance(tool, dict) and "name" in tool:
stream_tool_names.add(tool["name"])
for func in skill_manager.skill_functions_cache:
if func.get("name") not in stream_tool_names:
stream_tools.append({
"type": "function",
"function": func
})
chat_request["tools"] = stream_tools
logger.info(f"[TOOL-INJECT] streaming: injected skill tools into chat_request")

Check warning on line 346 in src/open_responses_server/api_controller.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add replacement fields or use a normal string instead of an f-string.

See more on https://sonarcloud.io/project/issues?id=teabranch_open-responses-server&issues=AZ1DCZC-BjcZnSaxOWqf&open=AZ1DCZC-BjcZnSaxOWqf&pullRequest=53

# Log the initial Chat Completions request payload
logger.info(f"Sending Chat Completions request: {json.dumps(chat_request)}")
client = await LLMClient.get_client()
Expand Down
54 changes: 47 additions & 7 deletions src/open_responses_server/chat_completions_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from open_responses_server.common.llm_client import LLMClient
from open_responses_server.common.config import logger, OPENAI_BASE_URL_INTERNAL, OPENAI_API_KEY, MAX_TOOL_CALL_ITERATIONS, STREAM_TIMEOUT
from open_responses_server.common.mcp_manager import mcp_manager, serialize_tool_result
from open_responses_server.common.skill_manager import skill_manager

async def _handle_non_streaming_request(client: LLMClient, request_data: dict):
"""Handles a non-streaming chat completions request with potential tool calls."""
Expand Down Expand Up @@ -59,9 +60,19 @@
except Exception as e:
logger.error(f"[CHAT-COMPLETIONS-NON-STREAM] ✗ Error executing tool {tool_name}: {e}")
tool_content = json.dumps({"error": f"Error executing tool: {e}"})
elif skill_manager.is_skill_tool(tool_name):
logger.info(f"[CHAT-COMPLETIONS-NON-STREAM] Executing skill tool: {tool_name}")
try:
arguments = json.loads(function_call.get("arguments", "{}"))
result = await skill_manager.execute_skill_tool(tool_name, arguments)
logger.info(f"[CHAT-COMPLETIONS-NON-STREAM] ✓ Skill tool {tool_name} executed successfully")
tool_content = result
except Exception as e:
logger.error(f"[CHAT-COMPLETIONS-NON-STREAM] ✗ Error executing skill tool {tool_name}: {e}")
tool_content = json.dumps({"error": f"Error executing skill tool: {e}"})
else:
logger.warning(f"[CHAT-COMPLETIONS-NON-STREAM] Tool '{tool_name}' is not a registered MCP tool.")
tool_content = json.dumps({"error": f"Tool '{tool_name}' is not a registered MCP tool."})
logger.warning(f"[CHAT-COMPLETIONS-NON-STREAM] Tool '{tool_name}' is not a registered server tool.")
tool_content = json.dumps({"error": f"Tool '{tool_name}' is not a registered server tool."})
Comment thread
OriNachum marked this conversation as resolved.

tool_results_messages.append({
"tool_call_id": tool_call_id,
Expand Down Expand Up @@ -131,15 +142,23 @@
result = await mcp_manager.execute_mcp_tool(tool_name, arguments)
logger.info(f"[CHAT-COMPLETIONS-STREAM] ✓ Tool {tool_name} executed successfully")
logger.debug(f"[CHAT-COMPLETIONS-STREAM] Tool result: {result}")
#result is serlized as: "meta=None content=[TextContent(type='text', text="[{'name': 'listings'}]", annotations=None)] isError=False"
# so we need to convert it to json
tool_content = serialize_tool_result(result)
except Exception as e:
logger.error(f"[CHAT-COMPLETIONS-STREAM] ✗ Error executing tool {tool_name}: {e}")
tool_content = json.dumps({"error": f"Error executing tool: {e}"})
elif skill_manager.is_skill_tool(tool_name):
logger.info(f"[CHAT-COMPLETIONS-STREAM] Executing skill tool: {tool_name}")
try:
arguments = json.loads(function_call.get("arguments", "{}"))
result = await skill_manager.execute_skill_tool(tool_name, arguments)
logger.info(f"[CHAT-COMPLETIONS-STREAM] ✓ Skill tool {tool_name} executed successfully")
tool_content = result
except Exception as e:
logger.error(f"[CHAT-COMPLETIONS-STREAM] ✗ Error executing skill tool {tool_name}: {e}")
tool_content = json.dumps({"error": f"Error executing skill tool: {e}"})
else:
logger.warning(f"[CHAT-COMPLETIONS-STREAM] Tool '{tool_name}' is not a registered MCP tool.")
tool_content = json.dumps({"error": f"Tool '{tool_name}' is not a registered MCP tool."})
logger.warning(f"[CHAT-COMPLETIONS-STREAM] Tool '{tool_name}' is not a registered server tool.")
tool_content = json.dumps({"error": f"Tool '{tool_name}' is not a registered server tool."})

tool_results_messages.append({
"tool_call_id": tool_call_id,
Expand Down Expand Up @@ -192,7 +211,7 @@
return StreamingResponse(final_error_stream(), media_type="text/event-stream", status_code=500)


async def handle_chat_completions(request: Request):

Check failure on line 214 in src/open_responses_server/chat_completions_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=teabranch_open-responses-server&issues=AZ1DCZDUBjcZnSaxOWqg&open=AZ1DCZDUBjcZnSaxOWqg&pullRequest=53
"""
Handles requests to the /v1/chat/completions endpoint.
Injects MCP tools and proxies the request to the underlying LLM API.
Expand Down Expand Up @@ -223,7 +242,28 @@
logger.info(f"[CHAT-COMPLETIONS] Final tool count: {len(existing_tools)}")
else:
logger.info("[CHAT-COMPLETIONS] No MCP tools available to inject")


# Inject skill tools into the request
skill_tools = skill_manager.get_skill_tools()
if skill_tools:
existing_tools = request_data.get("tools", [])
existing_tool_names = set()
for tool in existing_tools:
fn = tool.get("function", {})
if fn.get("name"):
existing_tool_names.add(fn["name"])
elif tool.get("name"):
existing_tool_names.add(tool["name"])

added_skills = []
for tool in skill_tools:
if tool.get("name") not in existing_tool_names:
existing_tools.append({"type": "function", "function": tool})
added_skills.append(tool.get("name"))

request_data["tools"] = existing_tools
logger.info(f"[CHAT-COMPLETIONS] Added {len(added_skills)} skill tools: {added_skills}")

logger.debug(f"[CHAT-COMPLETIONS] Final tools in request: {request_data.get('tools', [])}")

# Determine if the request is streaming
Expand Down
7 changes: 7 additions & 0 deletions src/open_responses_server/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
STREAM_TIMEOUT = float(os.environ.get("STREAM_TIMEOUT", "120.0"))
HEARTBEAT_INTERVAL = float(os.environ.get("HEARTBEAT_INTERVAL", "15.0"))

# Agent Skills Configuration
SKILLS_ENABLED = os.environ.get("SKILLS_ENABLED", "false").lower() in ("true", "1", "yes")
SKILLS_DIR = os.environ.get("SKILLS_DIR", "")
SKILLS_EXEC_TIMEOUT = int(os.environ.get("SKILLS_EXEC_TIMEOUT", "30"))

# --- Logging Configuration ---

Expand Down Expand Up @@ -61,3 +65,6 @@ def setup_logging():
logger.info(f" MAX_TOOL_CALL_ITERATIONS: {MAX_TOOL_CALL_ITERATIONS}")
logger.info(f" STREAM_TIMEOUT: {STREAM_TIMEOUT}")
logger.info(f" HEARTBEAT_INTERVAL: {HEARTBEAT_INTERVAL}")
logger.info(f" SKILLS_ENABLED: {SKILLS_ENABLED}")
logger.info(f" SKILLS_DIR: {SKILLS_DIR}")
logger.info(f" SKILLS_EXEC_TIMEOUT: {SKILLS_EXEC_TIMEOUT}")
Loading
Loading