Enhance DYNO mock server and filesystem handling: add tool-call support, improve error handling for directory listings, and ensure dangling symlinks are included in directory outputs.

This commit is contained in:
Cheikh Seck committed 2026-04-22 19:23:13 +00:00
1 parent a817486d55
commit 8e0a473917
4 files changed
+821 -37

No files matched your search

+11 -1
View File
@@ -4,7 +4,17 @@ providers:
endpoint: http://localhost:8888/v1
model: gpt-4
api_key: dummy-key-for-testing
mcp_servers:
- name: filesystem
allowed_paths:
- /dyno
- /tmp
- name: bash
allowed_paths:
- /dyno
- /tmp
- name: webscraper
# Leave allowed_paths empty to allow all URLs in DYNO.
default_provider: dyno-mock
vision_enabled: true
vision_cli_downloaded: true
+779 -33
View File
@@ -30,6 +30,596 @@ logger = logging.getLogger('dyno-mock')
# Request tracking
request_log_lock = threading.Lock()
request_counter = {"count": 0}
# Tool-call payloads for godex's non-native tool calling loop.
# godex expects one JSON object per markdown ```json``` block, with keys:
# {"name": "<tool_name>", "arguments": {...}}
#
# Note: DYNO's dummy provider config must also enable MCP servers for these tools
# to actually exist; otherwise godex will log them as "Ignored unknown tool(s)".
DYNO_TOOL_CALL_BATCHES = [
# Batch 1: independent calls + create/write (read happens later to avoid parallel races).
[
{"name": "list_directory", "arguments": {"path": "/dyno/workspace"}},
{"name": "search_files", "arguments": {"path": "/dyno", "pattern": "*.py"}},
{"name": "get_file_info", "arguments": {"path": "/dyno/dyno-mock-server.py"}},
{
"name": "read_file_line_range",
"arguments": {"path": "/dyno/dyno-mock-server.py", "start_line": 1, "end_line": 40},
},
{"name": "search_in_file", "arguments": {"path": "/dyno/dyno-mock-server.py", "pattern": "def "}},
{"name": "create_directory", "arguments": {"path": "/dyno/workspace/example_dir"}},
{
"name": "write_file",
"arguments": {
"path": "/dyno/workspace/example_dir/example.txt",
"content": "hello from dyno\nsecond line\nthird line\n",
},
},
{"name": "run_command", "arguments": {"command": "ls -la /dyno/workspace"}},
{"name": "run_python", "arguments": {"code": "print('dyno python ok')"}},
{"name": "run_node", "arguments": {"code": "console.log('dyno node ok')"}},
{"name": "run_bash_script", "arguments": {"code": "echo 'dyno bash ok'"}},
# Webscraper tool that doesn't require a browser: parse links from provided HTML.
{
"name": "get_links",
"arguments": {
"html": "<html><body><a href='/x'>x</a><a href='https://example.com/y'>y</a></body></html>",
"base_url": "https://example.com",
},
},
],
# Batch 2: dependent read after write_file has completed.
[
{"name": "read_file", "arguments": {"path": "/dyno/workspace/example_dir/example.txt"}},
{
"name": "replace_first_in_file",
"arguments": {
"path": "/dyno/workspace/example_dir/example.txt",
"find": "hello from dyno",
"replace": "hello from dyno (updated)",
},
},
{
"name": "insert_at_line",
"arguments": {
"path": "/dyno/workspace/example_dir/example.txt",
"line": 0,
"content": "inserted line",
},
},
{
"name": "replace_line_range",
"arguments": {
"path": "/dyno/workspace/example_dir/example.txt",
"start_line": 2,
"end_line": 2,
"new_content": "replaced second line",
},
},
{
"name": "search_directory_text",
"arguments": {"path": "/dyno/workspace", "query": "replaced second line"},
},
{
"name": "search_file_text",
"arguments": {"path": "/dyno/workspace", "query": "updated"},
},
],
# Batch 3: cleanup (avoid interfering with Batch 2 reads/modifications).
[
{"name": "delete_file", "arguments": {"path": "/dyno/workspace/example_dir/example.txt"}},
{"name": "list_directory", "arguments": {"path": "/dyno/workspace/example_dir"}},
],
]
def render_tool_blocks(tool_calls):
return "\n".join("```json\n" + json.dumps(tc) + "\n```" for tc in tool_calls)
# All tools currently used by the system
SYSTEM_TOOLS = [
{
"type": "function",
"function": {
"name": "list_directory",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The directory path to list"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read contents of a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to read"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to write"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command and return its output",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The command to run"},
"background": {"type": "boolean", "description": "Run in background"}
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file_line_range",
"description": "Read a specific range of lines from a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path"},
"start_line": {"type": "integer", "description": "Start line number"},
"end_line": {"type": "integer", "description": "End line number"}
},
"required": ["path", "start_line", "end_line"]
}
}
},
{
"type": "function",
"function": {
"name": "search_in_file",
"description": "Search for text within a specific file",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Pattern to search"},
"path": {"type": "string", "description": "File path"}
},
"required": ["pattern", "path"]
}
}
},
{
"type": "function",
"function": {
"name": "get_file_info",
"description": "Get information about a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "create_directory",
"description": "Create a new directory",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path to create"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "delete_file",
"description": "Delete a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to delete"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "search_files",
"description": "Search for files matching a pattern",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory to search"},
"pattern": {"type": "string", "description": "File pattern"}
},
"required": ["path", "pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "run_python",
"description": "Run a Python script and return its output",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to run"}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "run_node",
"description": "Run a Node.js script and return its output",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Node.js code to run"}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "fetch_url",
"description": "Fetch a URL and return rendered HTML",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch"}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "replace_first_in_file",
"description": "Replace the first occurrence of a text string in a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"},
"old": {"type": "string", "description": "Text to replace"},
"new": {"type": "string", "description": "Replacement text"}
},
"required": ["path", "old", "new"]
}
}
},
{
"type": "function",
"function": {
"name": "replace_line_range",
"description": "Replace a range of lines in a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"},
"start": {"type": "integer", "description": "Start line"},
"end": {"type": "integer", "description": "End line"},
"new": {"type": "string", "description": "New content"}
},
"required": ["path", "start", "end", "new"]
}
}
},
{
"type": "function",
"function": {
"name": "insert_at_line",
"description": "Insert content at a specific line number",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"},
"line": {"type": "integer", "description": "Line number"},
"content": {"type": "string", "description": "Content to insert"}
},
"required": ["path", "line", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "search_file_text",
"description": "Search for text content within files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path"},
"pattern": {"type": "string", "description": "Search pattern"}
},
"required": ["path", "pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "search_directory_text",
"description": "Search for text content within files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory path"},
"pattern": {"type": "string", "description": "Search pattern"}
},
"required": ["path", "pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "read_pdf",
"description": "Extract text from a PDF file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "PDF file path"},
"pages": {"type": "string", "description": "Page range (e.g., '1-5')"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "read_text",
"description": "Extract text from an image using OCR",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Image file path"}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "get_links",
"description": "Extract all links from HTML content",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch"}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "list_connected_tabs",
"description": "List all tabs connected via Chrome extension",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "get_page_source",
"description": "Get raw HTML source from a Chrome tab",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"}
},
"required": ["tab_id"]
}
}
},
{
"type": "function",
"function": {
"name": "extract_page_content",
"description": "Extract readable text, links, forms, images from page",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"}
},
"required": ["tab_id"]
}
}
},
{
"type": "function",
"function": {
"name": "click_element",
"description": "Click an element on the page using CSS selector",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"selector": {"type": "string", "description": "CSS selector"}
},
"required": ["tab_id", "selector"]
}
}
},
{
"type": "function",
"function": {
"name": "find_elements",
"description": "Find DOM elements using CSS/XPath",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"selector": {"type": "string", "description": "CSS/XPath selector"}
},
"required": ["tab_id", "selector"]
}
}
},
{
"type": "function",
"function": {
"name": "get_visible_elements",
"description": "Get all visible elements with text content",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"}
},
"required": ["tab_id"]
}
}
},
{
"type": "function",
"function": {
"name": "search_text",
"description": "Search for text within visible page elements",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"text": {"type": "string", "description": "Text to search"}
},
"required": ["tab_id", "text"]
}
}
},
{
"type": "function",
"function": {
"name": "wait_for_element",
"description": "Wait for element to appear",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"selector": {"type": "string", "description": "CSS selector"}
},
"required": ["tab_id", "selector"]
}
}
},
{
"type": "function",
"function": {
"name": "execute_script",
"description": "Execute JavaScript in a connected tab",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"script": {"type": "string", "description": "JavaScript code"}
},
"required": ["tab_id", "script"]
}
}
},
{
"type": "function",
"function": {
"name": "get_tab_info",
"description": "Get info about a connected tab",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"}
},
"required": ["tab_id"]
}
}
},
{
"type": "function",
"function": {
"name": "get_element_details",
"description": "Get detailed element info",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"},
"selector": {"type": "string", "description": "CSS selector"}
},
"required": ["tab_id", "selector"]
}
}
},
{
"type": "function",
"function": {
"name": "get_page_structure",
"description": "Get structured DOM overview of the page",
"parameters": {
"type": "object",
"properties": {
"tab_id": {"type": "integer", "description": "Tab ID"}
},
"required": ["tab_id"]
}
}
},
{
"type": "function",
"function": {
"name": "scheduler",
"description": "Schedule a task to run at a specific time or interval",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "Prompt to run"},
"interval_sec": {"type": "integer", "description": "Interval in seconds"},
"run_at": {"type": "string", "description": "Specific time (e.g., '14:30')"},
"run_once": {"type": "boolean", "description": "Run only once"}
},
"required": ["prompt"]
}
}
}
]
def log_request(method, url, headers, body, response_status, response_body):
"""Log request to tracking file"""
@@ -60,8 +650,8 @@ class MockHandler(BaseHTTPRequestHandler):
def get_mock_response(self, method, path, headers=None, body=None):
"""Determine mock response based on request"""
# Ollama endpoints
if '/api/chat' in path:
# Ollama endpoints (including /v1/api/chat variant)
if '/api/chat' in path or '/v1/api/chat' in path:
return self.mock_ollama_chat(body)
if '/api/show' in path:
return self.mock_ollama_show(body)
@@ -76,11 +666,13 @@ class MockHandler(BaseHTTPRequestHandler):
if '/v1/embeddings' in path or 'embeddings' in path:
return self.mock_embeddings_response()
# Default: generic success response
return self.mock_default_response()
# Default
return self.mock_default()
def mock_ollama_chat(self, body):
"""Mock Ollama chat response"""
"""Mock Ollama chat response with tools support"""
global request_counter
try:
req_data = json.loads(body) if body else {}
except:
@@ -89,14 +681,52 @@ class MockHandler(BaseHTTPRequestHandler):
model = req_data.get('model', 'gpt-4')
messages = req_data.get('messages', [])
# Build a response
# Check if this is a tool result response
last_message = messages[-1] if messages else {}
is_tool_result = last_message.get('role') == 'tool'
# Increment request counter
request_counter["count"] += 1
request_num = request_counter["count"]
batch_index = request_num - 1
if 0 <= batch_index < len(DYNO_TOOL_CALL_BATCHES):
logger.info(
f"Request #{request_num} - returning tool batch {batch_index + 1}/{len(DYNO_TOOL_CALL_BATCHES)}"
)
else:
logger.info(f"Request #{request_num} - returning final answer")
# NOTE: godex's Ollama provider expects streamed JSON objects containing
# `message.content`. It does not read Ollama-native tool_calls.
if 0 <= batch_index < len(DYNO_TOOL_CALL_BATCHES):
tool_calls_content = render_tool_blocks(DYNO_TOOL_CALL_BATCHES[batch_index])
response = {
"model": model,
"created_at": datetime.now().isoformat() + "Z",
"message": {
"role": "assistant",
"content": tool_calls_content,
},
"done": True,
"prompt_eval_count": 0,
"eval_count": 0,
}
return 200, response
# All batches done: return a final answer (the real tool output is handled by godex).
response = {
"model": model,
"created_at": datetime.now().isoformat() + "Z",
"message": {
"role": "assistant",
"content": "[DYNO MOCK] This is a simulated AI response. All network calls are being tracked and mocked for testing purposes."
"content": (
"I executed the requested tool calls and am ready to answer based on the results."
),
},
"done": True
"done": True,
"prompt_eval_count": 0,
"eval_count": 0,
}
return 200, response
@@ -148,31 +778,139 @@ class MockHandler(BaseHTTPRequestHandler):
return 200, response
def mock_chat_completion(self, body):
"""Mock OpenAI chat completion"""
"""Mock OpenAI chat completion with tools support"""
global request_counter
try:
req_data = json.loads(body) if body else {}
except:
req_data = {}
response = {
"id": "chatcmpl-mock-dyno-001",
"object": "chat.completion",
"created": int(datetime.now().timestamp()),
"model": req_data.get('model', 'gpt-4'),
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "[DYNO MOCK] This is a simulated AI response."
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 50,
"total_tokens": 100
model = req_data.get('model', 'gpt-4')
messages = req_data.get('messages', [])
# Check if this is a tool result response
last_message = messages[-1] if messages else {}
is_tool_result = last_message.get('role') == 'tool'
# Increment request counter
request_counter["count"] += 1
request_num = request_counter["count"]
logger.info(f"Request #{request_num} - Tool result: {is_tool_result}")
if not is_tool_result and request_num == 1:
# First request: return all tools
response = {
"id": "chatcmpl-mock-dyno-001",
"object": "chat.completion",
"created": int(datetime.now().timestamp()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": f"call_{i+1}",
"type": "function",
"function": {
"name": SYSTEM_TOOLS[i]["function"]["name"],
"arguments": json.dumps({"path": "/home/cheikh-seck/godex/dyno"})
}
}
for i in range(len(SYSTEM_TOOLS))
]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 50,
"total_tokens": 100
}
}
}
elif is_tool_result:
# Tool result received - continue with more tools or final answer
tool_results = [m for m in messages if m.get('role') == 'tool']
if len(tool_results) >= len(SYSTEM_TOOLS):
# All tools executed - return final answer
response = {
"id": f"chatcmpl-mock-dyno-{request_num}",
"object": "chat.completion",
"created": int(datetime.now().timestamp()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Here are the files in the dyno directory:\n- .gitignore\n- .gtree\n- Dockerfile\n- README.md\n- dummy-provider.yaml\n- dyno-entrypoint.sh\n- dyno-mock-server.py\n- dyno-run.sh\n- dyno.env\n\nd logs/\nd workspace/\n\nI have successfully called all 32 system tools and compiled the results."
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 100,
"total_tokens": 150
}
}
else:
# Return more tool calls
remaining_tools = SYSTEM_TOOLS[len(tool_results):]
response = {
"id": f"chatcmpl-mock-dyno-{request_num}",
"object": "chat.completion",
"created": int(datetime.now().timestamp()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": f"call_{len(tool_results) + i + 1}",
"type": "function",
"function": {
"name": remaining_tools[i]["function"]["name"],
"arguments": json.dumps({"path": "/home/cheikh-seck/godex/dyno"})
}
}
for i in range(min(5, len(remaining_tools)))
]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 50,
"total_tokens": 100
}
}
else:
# Second request (no tools): final answer
response = {
"id": "chatcmpl-mock-dyno-002",
"object": "chat.completion",
"created": int(datetime.now().timestamp()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Here are the files in the dyno directory:\n- .gitignore\n- .gtree\n- Dockerfile\n- README.md\n- dummy-provider.yaml\n- dyno-entrypoint.sh\n- dyno-mock-server.py\n- dyno-run.sh\n- dyno.env\n\nd logs/\nd workspace/"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 100,
"total_tokens": 150
}
}
return 200, response
def mock_embeddings_response(self):
@@ -192,11 +930,11 @@ class MockHandler(BaseHTTPRequestHandler):
}
return 200, response
def mock_default_response(self):
"""Default mock response"""
def mock_default(self):
"""Mock default response"""
response = {
"status": "mocked",
"message": "DYNO mock server - all network calls are simulated",
"status": "ok",
"message": "DYNO mock server is running",
"timestamp": datetime.now().isoformat()
}
return 200, response
@@ -208,10 +946,14 @@ class MockHandler(BaseHTTPRequestHandler):
status, response = self.get_mock_response('GET', path)
response_bytes = json.dumps(response).encode("utf-8")
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Length", str(len(response_bytes)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
self.wfile.write(response_bytes)
self.wfile.flush()
def do_POST(self):
"""Handle POST requests"""
@@ -226,10 +968,14 @@ class MockHandler(BaseHTTPRequestHandler):
# Log the request
log_request('POST', path, dict(self.headers), body, status, json.dumps(response))
response_bytes = json.dumps(response).encode("utf-8")
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Length", str(len(response_bytes)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
self.wfile.write(response_bytes)
self.wfile.flush()
def main():
+10 -3
View File
@@ -345,12 +345,19 @@ func (s *FileSystemServer) listDirectory(args map[string]interface{}) (string, e
var result strings.Builder
for _, entry := range entries {
info, _ := entry.Info()
if entry.IsDir() {
result.WriteString(fmt.Sprintf("d %s/\n", entry.Name()))
} else {
result.WriteString(fmt.Sprintf("- %s (%d bytes)\n", entry.Name(), info.Size()))
continue
}
// entry.Info() can fail (e.g. dangling symlink, TOCTOU deletion during parallel tool runs).
// Don't panic; just omit the size.
info, err := entry.Info()
if err != nil || info == nil {
result.WriteString(fmt.Sprintf("- %s\n", entry.Name()))
continue
}
result.WriteString(fmt.Sprintf("- %s (%d bytes)\n", entry.Name(), info.Size()))
}
return result.String(), nil
+21
View File
@@ -57,3 +57,24 @@ func TestFileSystemServer_SanitizesEmptyAllowedPaths(t *testing.T) {
t.Fatalf("expected default allowed path, got: %v", paths)
}
}
func TestFileSystemServer_ListDirectory_DoesNotPanicOnInfoError(t *testing.T) {
tmp := t.TempDir()
// Create a dangling symlink so DirEntry.Info() returns an error.
linkPath := filepath.Join(tmp, "dangling-link")
if err := os.Symlink(filepath.Join(tmp, "does-not-exist"), linkPath); err != nil {
t.Skipf("symlink not supported: %v", err)
}
server := NewFileSystemServer([]string{tmp}, false)
out, err := server.CallTool(context.Background(), "list_directory", map[string]interface{}{
"path": tmp,
})
if err != nil {
t.Fatalf("list_directory failed: %v", err)
}
if !strings.Contains(out, "dangling-link") {
t.Fatalf("expected listing to include dangling-link, got: %s", out)
}
}