diff --git a/docs/tools/filesystem/index.md b/docs/tools/filesystem/index.md index 19b0be53e1..a469a1a5e6 100644 --- a/docs/tools/filesystem/index.md +++ b/docs/tools/filesystem/index.md @@ -44,7 +44,7 @@ This helps agents distinguish between an empty directory and a tool failure, avo | `read_file` | Read the contents of a file (whole file, or a line range of a text file) | | `read_multiple_files` | Read several files in one call (more efficient than multiple `read_file`) | | `write_file` | Create or overwrite a file with new content | -| `edit_file` | Make line-based edits (find-and-replace) in an existing file. Each edit must specify a non-empty `oldText` to match and replace; empty `oldText` values are rejected with an error. | +| `edit_file` | Make line-based edits (find-and-replace) in an existing file. Each edit must specify a non-empty `oldText` that matches exactly once in the file; empty `oldText` values and ambiguous matches are rejected with an error naming the occurrence count. Add surrounding context to disambiguate, or send one edit per occurrence to change several. | | `list_directory` | List files and directories at a given path (explicitly reports empty directories) | | `directory_tree` | Recursive tree view of a directory | | `create_directory` | Create a new directory (creates parent directories as needed) | diff --git a/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml index b16bf9704b..3f07f7a808 100644 --- a/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.anthropic.com - body: '{"max_tokens":64000,"messages":[{"content":[{"text":"How many files in testdata/working_dir? Only output the number.","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-0","system":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.","type":"text"},{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","cache_control":{"type":"ephemeral"},"type":"text"}],"tools":[{"input_schema":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"},"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure."},{"input_schema":{"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"},"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content."},{"input_schema":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"},"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path."},{"input_schema":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly."},{"input_schema":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously."},{"input_schema":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"},"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern."},{"input_schema":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content."},{"input_schema":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"create_directory","description":"Create one or more new directories or nested directory structures."},{"input_schema":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"remove_directory","description":"Remove one or more empty directories."}],"stream":true}' + body: '{"max_tokens":64000,"messages":[{"content":[{"text":"How many files in testdata/working_dir? Only output the number.","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-0","system":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.","type":"text"},{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","cache_control":{"type":"ephemeral"},"type":"text"}],"tools":[{"input_schema":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"},"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure."},{"input_schema":{"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"},"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once."},{"input_schema":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"},"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path."},{"input_schema":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly."},{"input_schema":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously."},{"input_schema":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"},"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern."},{"input_schema":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content."},{"input_schema":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"create_directory","description":"Create one or more new directories or nested directory structures."},{"input_schema":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"remove_directory","description":"Remove one or more empty directories."}],"stream":true}' url: https://api.anthropic.com/v1/messages method: POST response: @@ -55,7 +55,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.anthropic.com - body: '{"max_tokens":64000,"messages":[{"content":[{"text":"How many files in testdata/working_dir? Only output the number.","type":"text"}],"role":"user"},{"content":[{"id":"toolu_012gmfqnoTX8c5aV3vMWUnas","input":{"path":"testdata/working_dir"},"name":"list_directory","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_012gmfqnoTX8c5aV3vMWUnas","is_error":false,"cache_control":{"type":"ephemeral"},"content":[{"text":"FILE README.me\n","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-0","system":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.","type":"text"},{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","cache_control":{"type":"ephemeral"},"type":"text"}],"tools":[{"input_schema":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"},"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure."},{"input_schema":{"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"},"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content."},{"input_schema":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"},"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path."},{"input_schema":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly."},{"input_schema":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously."},{"input_schema":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"},"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern."},{"input_schema":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content."},{"input_schema":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"create_directory","description":"Create one or more new directories or nested directory structures."},{"input_schema":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"remove_directory","description":"Remove one or more empty directories."}],"stream":true}' + body: '{"max_tokens":64000,"messages":[{"content":[{"text":"How many files in testdata/working_dir? Only output the number.","type":"text"}],"role":"user"},{"content":[{"id":"toolu_012gmfqnoTX8c5aV3vMWUnas","input":{"path":"testdata/working_dir"},"name":"list_directory","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_012gmfqnoTX8c5aV3vMWUnas","is_error":false,"cache_control":{"type":"ephemeral"},"content":[{"text":"FILE README.me\n","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-0","system":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.","type":"text"},{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","cache_control":{"type":"ephemeral"},"type":"text"}],"tools":[{"input_schema":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"},"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure."},{"input_schema":{"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"},"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once."},{"input_schema":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"},"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path."},{"input_schema":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly."},{"input_schema":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously."},{"input_schema":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"},"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern."},{"input_schema":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content."},{"input_schema":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"create_directory","description":"Create one or more new directories or nested directory structures."},{"input_schema":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"},"name":"remove_directory","description":"Remove one or more empty directories."}],"stream":true}' url: https://api.anthropic.com/v1/messages method: POST response: diff --git a/e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml index 6588b8dcb4..8307069d15 100644 --- a/e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml @@ -9,7 +9,7 @@ interactions: content_length: 0 host: generativelanguage.googleapis.com body: | - {"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Edits to apply","items":{"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":"integer"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":"integer"},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]} + {"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Edits to apply","items":{"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":"integer"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":"integer"},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]} form: alt: - sse @@ -33,7 +33,7 @@ interactions: content_length: 0 host: generativelanguage.googleapis.com body: | - {"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"},{"parts":[{"functionCall":{"args":{"path":"testdata/working_dir"},"name":"list_directory"},"thoughtSignature":"c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="}],"role":"model"},{"parts":[{"functionResponse":{"name":"call_3df8565b-a1ef-4490-95f9-5d94296d7687","response":{"result":"FILE README.me\n"}}}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Edits to apply","items":{"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":"integer"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":"integer"},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]} + {"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"},{"parts":[{"functionCall":{"args":{"path":"testdata/working_dir"},"name":"list_directory"},"thoughtSignature":"c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="}],"role":"model"},{"parts":[{"functionResponse":{"name":"call_3df8565b-a1ef-4490-95f9-5d94296d7687","response":{"result":"FILE README.me\n"}}}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Edits to apply","items":{"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":"integer"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":"integer"},"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Return result as JSON","type":"boolean"},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},"is_regex":{"description":"Treat query as regex","type":"boolean"},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]} form: alt: - sse diff --git a/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml index ab0a21e909..416e989682 100644 --- a/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.mistral.ai - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.mistral.ai/v1/chat/completions method: POST response: @@ -34,7 +34,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.mistral.ai - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"D9WYdiHxV","function":{"arguments":"{\"path\": \"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"D9WYdiHxV","role":"tool"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"D9WYdiHxV","function":{"arguments":"{\"path\": \"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"D9WYdiHxV","role":"tool"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.mistral.ai/v1/chat/completions method: POST response: diff --git a/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml b/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml index 7702ab6f12..19efb5a03d 100644 --- a/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml +++ b/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: @@ -54,7 +54,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_dsl9jWekN0H1do1ClfeyR1iA","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_dsl9jWekN0H1do1ClfeyR1iA","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_dsl9jWekN0H1do1ClfeyR1iA","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_dsl9jWekN0H1do1ClfeyR1iA","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: diff --git a/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml index 36600c9291..f071a38ffe 100644 --- a/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: @@ -54,7 +54,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_I1tmAsYKD7bveEpXORJwVFgs","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_I1tmAsYKD7bveEpXORJwVFgs","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- The working directory is \"/tmp/wd\"; relative paths resolve from it\n- Absolute paths must match the host OS (e.g. C:\\... on Windows, /... on Unix)\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_I1tmAsYKD7bveEpXORJwVFgs","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_I1tmAsYKD7bveEpXORJwVFgs","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.","parameters":{"additionalProperties":false,"properties":{"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"newText":{"description":"Replacement text","type":"string"},"oldText":{"description":"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several.","type":"string"}},"required":["newText","oldText"],"type":"object"},"type":"array"},"path":{"description":"File to edit","type":"string"}},"required":["edits","path"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"path":{"description":"File to read","type":"string"}},"required":["limit","line","path"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"json":{"description":"Return result as JSON","type":["boolean","null"]},"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"}},"required":["json","paths"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"}},"required":["excludePatterns","is_regex","path","query"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"content":{"description":"File content","type":"string"},"path":{"description":"File to write","type":"string"}},"required":["content","path"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: diff --git a/pkg/acp/filesystem.go b/pkg/acp/filesystem.go index 618298d62c..f6d9158f10 100644 --- a/pkg/acp/filesystem.go +++ b/pkg/acp/filesystem.go @@ -282,15 +282,11 @@ func (t *FilesystemToolset) handleEditFile(ctx context.Context, toolCall tools.T modifiedContent := resp.Content for i, edit := range args.Edits { - // strings.Contains always matches "" and strings.Replace would insert - // newText at offset 0, silently prepending to the file. Mirrors the - // guard in the built-in filesystem toolset, which serves the same - // edit_file tool name and schema over a different transport. - if edit.OldText == "" { - return tools.ResultError(fmt.Sprintf("Edit %d failed: oldText must not be empty", i+1)), nil - } - if !strings.Contains(modifiedContent, edit.OldText) { - return tools.ResultError(fmt.Sprintf("Edit %d failed: old text not found", i+1)), nil + // Shared with the built-in filesystem toolset: this handler overrides the + // same edit_file tool name and schema, so both must agree on what a valid + // edit is or the call means different things depending on transport. + if reason := filesystem.EditFailureReason(modifiedContent, edit); reason != "" { + return tools.ResultError(fmt.Sprintf("Edit %d failed: %s", i+1, reason)), nil } modifiedContent = strings.Replace(modifiedContent, edit.OldText, edit.NewText, 1) } diff --git a/pkg/tools/builtin/filesystem/filesystem.go b/pkg/tools/builtin/filesystem/filesystem.go index 1fab47cc94..db2b53fae8 100644 --- a/pkg/tools/builtin/filesystem/filesystem.go +++ b/pkg/tools/builtin/filesystem/filesystem.go @@ -337,7 +337,7 @@ type ReadFileMeta struct { } type Edit struct { - OldText string `json:"oldText" jsonschema:"Exact text to replace"` + OldText string `json:"oldText" jsonschema:"Exact text to replace. Must be non-empty and must match exactly once in the file: include surrounding context to disambiguate, or send one edit per occurrence to change several."` NewText string `json:"newText" jsonschema:"Replacement text"` } @@ -530,7 +530,7 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { { Name: ToolNameEditFile, Category: "filesystem", - Description: "Make line-based edits to a text file. Each edit replaces exact line sequences with new content.", + Description: "Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Every oldText must match exactly once.", Parameters: tools.MustSchemaFor[EditFileArgs](), OutputSchema: tools.MustSchemaFor[string](), Handler: t.editFileHandler(), @@ -1010,6 +1010,39 @@ func (t *ToolSet) editFileHandler() tools.ToolHandler { } } +// EditFailureReason reports why edit cannot be applied to content, or an empty +// string when it can be applied to exactly one site. +// +// It is exported because the ACP toolset overrides edit_file with its own +// client-backed handler while serving the same tool name and schema +// (pkg/acp/filesystem.go). Both loops must agree on what a valid edit is, or the +// same tool call means different things depending on transport — so the rule +// lives here once rather than being duplicated per handler. +// +// Callers supply their own "Edit N failed: " prefix. +func EditFailureReason(content string, edit Edit) string { + // strings.Contains always matches "" and strings.Replace would insert + // newText at offset 0, silently prepending to the file. Checked before the + // occurrence count because strings.Count(s, "") returns the rune count plus + // one, which would otherwise report a meaningless "appears 42 times". + if edit.OldText == "" { + return "oldText must not be empty" + } + + switch n := strings.Count(content, edit.OldText); { + case n == 0: + return "old text not found" + case n > 1: + // Naming the count and both remedies matters: the model's intent may + // have been a single site (needs more context) or every site (needs one + // edit per occurrence, which is how this schema expresses replace-all). + // "Your text is wrong" alone would send it into a useless retry. + return fmt.Sprintf("old text appears %d times; include more surrounding context so it "+ + "matches exactly once, or send one edit per occurrence to change several", n) + } + return "" +} + func (t *ToolSet) handleEditFile(ctx context.Context, args EditFileArgs) (*tools.ToolCallResult, error) { annotateFilesystemSpan(ctx, "edit_file", args.Path) resolvedPath, err := t.resolveAndCheckPath(args.Path) @@ -1030,13 +1063,10 @@ func (t *ToolSet) handleEditFile(ctx context.Context, args EditFileArgs) (*tools var changes []string for i, edit := range args.Edits { - // strings.Contains always matches "" and strings.Replace would insert - // newText at offset 0, silently prepending to the file. - if edit.OldText == "" { - return tools.ResultError(fmt.Sprintf("Edit %d failed: oldText must not be empty", i+1)), nil - } - if !strings.Contains(modifiedContent, edit.OldText) { - return tools.ResultError(fmt.Sprintf("Edit %d failed: old text not found", i+1)), nil + // Checked against the running content, not the original: an earlier edit + // may legitimately have removed a duplicate. + if reason := EditFailureReason(modifiedContent, edit); reason != "" { + return tools.ResultError(fmt.Sprintf("Edit %d failed: %s", i+1, reason)), nil } modifiedContent = strings.Replace(modifiedContent, edit.OldText, edit.NewText, 1) changes = append(changes, fmt.Sprintf("Edit %d: Replaced %d characters", i+1, len(edit.OldText))) diff --git a/pkg/tools/builtin/filesystem/filesystem_test.go b/pkg/tools/builtin/filesystem/filesystem_test.go index 1165e5bf5f..58deed234d 100644 --- a/pkg/tools/builtin/filesystem/filesystem_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_test.go @@ -584,6 +584,103 @@ func TestFilesystemTool_EditFileRejectsEmptyOldText(t *testing.T) { }) } +// An oldText that matches more than once is ambiguous: strings.Replace(..., 1) +// would rewrite the first occurrence and report a plain success, so the model +// cannot tell whether it edited the site it meant. The caller has to +// disambiguate with more surrounding context instead. +func TestFilesystemTool_EditFileRejectsAmbiguousMatch(t *testing.T) { + t.Parallel() + + // The same assignment in two different blocks — a realistic shape. + const original = "def dev():\n debug = True\n\ndef prod():\n debug = True\n" + + t.Run("two occurrences are refused", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "conf.py"), []byte(original), 0o644)) + + result, err := tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{{OldText: " debug = True", NewText: " debug = False"}}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "appears 2 times") + + after, err := os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + assert.Equal(t, original, string(after), "an ambiguous edit must not modify the file") + }) + + t.Run("a uniquely matching edit still applies", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "conf.py"), []byte(original), 0o644)) + + // Enough surrounding context to match exactly once. + result, err := tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{{OldText: "def prod():\n debug = True", NewText: "def prod():\n debug = False"}}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + + after, err := os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + assert.Equal(t, "def dev():\n debug = True\n\ndef prod():\n debug = False\n", string(after)) + }) + + // Occurrences must be counted against the running content, not the original: + // an earlier edit can legitimately remove a duplicate and leave the later + // edit unambiguous. + t.Run("an earlier edit may resolve a later edit's ambiguity", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "conf.py"), []byte(original), 0o644)) + + result, err := tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{ + // Removes the first duplicate, using surrounding context. + {OldText: "def dev():\n debug = True", NewText: "def dev():\n debug = None"}, + // Now matches exactly once. + {OldText: " debug = True", NewText: " debug = False"}, + }, + }) + require.NoError(t, err) + assert.False(t, result.IsError, "got: %s", result.Output) + + after, err := os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + assert.Equal(t, "def dev():\n debug = None\n\ndef prod():\n debug = False\n", string(after)) + }) + + t.Run("an ambiguous later edit discards the earlier one", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "conf.py"), []byte(original), 0o644)) + + result, err := tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{ + {OldText: "def dev():", NewText: "def development():"}, + {OldText: " debug = True", NewText: " debug = False"}, + }, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "Edit 2") + + after, err := os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + assert.Equal(t, original, string(after), "no edit may be persisted when a later one is rejected") + }) +} + func TestParseEditFileArgs(t *testing.T) { t.Parallel() @@ -1647,3 +1744,86 @@ func TestFilesystemTool_EditFileHandlerRefusesWellFormedEmptyOldText(t *testing. require.NoError(t, err) assert.Equal(t, original, string(after)) } + +// EditFailureReason is the single rule both the built-in handler and the ACP +// override apply, so it is tested directly rather than only through them. +func TestEditFailureReason(t *testing.T) { + t.Parallel() + + const content = "a = 1\nb = 2\na = 1\n" + + t.Run("applicable edit has no reason", func(t *testing.T) { + t.Parallel() + assert.Empty(t, EditFailureReason(content, Edit{OldText: "b = 2", NewText: "b = 9"})) + }) + + t.Run("missing text", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "old text not found", + EditFailureReason(content, Edit{OldText: "nope", NewText: "x"})) + }) + + // strings.Count(s, "") returns the rune count plus one, so an empty oldText + // would otherwise fall into the n > 1 arm and report a meaningless + // "appears 19 times" with advice that cannot be satisfied. + t.Run("empty oldText gets its own message, not an occurrence count", func(t *testing.T) { + t.Parallel() + reason := EditFailureReason(content, Edit{OldText: "", NewText: "x"}) + assert.Equal(t, "oldText must not be empty", reason) + assert.NotContains(t, reason, "appears") + }) + + t.Run("ambiguous match names the count and both remedies", func(t *testing.T) { + t.Parallel() + reason := EditFailureReason(content, Edit{OldText: "a = 1", NewText: "a = 9"}) + assert.Contains(t, reason, "appears 2 times") + assert.Contains(t, reason, "more surrounding context") + // A model whose intent was *every* occurrence needs to be told how this + // schema expresses that, or it retries the same payload. + assert.Contains(t, reason, "one edit per occurrence") + }) +} + +// Repeating an identical edit used to be the way to express "change every +// occurrence", and refusing it is a deliberate behaviour change. The intent must +// still be expressible, which is what the error message now points at. +func TestFilesystemTool_EditFileMultiOccurrenceIntentRemainsExpressible(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tool := New(tmpDir) + const original = "a = 1\nb = 2\na = 1\n" + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "conf.py"), []byte(original), 0o644)) + + // The old spelling — the same edit twice — is now refused. + result, err := tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{ + {OldText: "a = 1", NewText: "a = 9"}, + {OldText: "a = 1", NewText: "a = 9"}, + }, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, result.Output, "one edit per occurrence") + + after, err := os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + require.Equal(t, original, string(after), "the refused batch must not have written") + + // One edit per occurrence, each carrying enough context to be unique, does + // what the model meant. + result, err = tool.handleEditFile(t.Context(), EditFileArgs{ + Path: "conf.py", + Edits: []Edit{ + {OldText: "a = 1\nb = 2", NewText: "a = 9\nb = 2"}, + {OldText: "b = 2\na = 1", NewText: "b = 2\na = 9"}, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError, result.Output) + + after, err = os.ReadFile(filepath.Join(tmpDir, "conf.py")) + require.NoError(t, err) + assert.Equal(t, "a = 9\nb = 2\na = 9\n", string(after)) +} diff --git a/pkg/tui/components/tool/editfile/render.go b/pkg/tui/components/tool/editfile/render.go index e18262cb64..d2b5a2359f 100644 --- a/pkg/tui/components/tool/editfile/render.go +++ b/pkg/tui/components/tool/editfile/render.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker-agent/pkg/concurrent" "github.com/docker/docker-agent/pkg/lrucache" "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) @@ -127,6 +128,10 @@ func renderEditFileUncached(toolCall tools.ToolCall, width int, splitView bool, return "" } + if notice := refusalNotice(args, toolStatus); notice != "" { + return notice + } + var output strings.Builder for i, edit := range args.Edits { if i > 0 { @@ -148,6 +153,43 @@ func renderEditFileUncached(toolCall tools.ToolCall, width int, splitView bool, return output.String() } +// refusalNotice returns the message to show in place of a diff when the edit +// the user is being asked to approve will be refused, or an empty string when +// the call is applicable. +// +// Without this, an ambiguous or empty oldText is previewed as a +// first-occurrence diff — strings.Replace(..., 1) always produces one — so the +// user approves a change the tool then declines, and the diff they saw was +// never the change that would have been made. +// +// Only meaningful before execution: once the tool has run, the file on disk is +// the outcome and there is nothing left to predict. +func refusalNotice(args filesystem.EditFileArgs, toolStatus types.ToolStatus) string { + if toolStatus != types.ToolStatusConfirmation { + return "" + } + + content, err := os.ReadFile(args.Path) + if err != nil { + // The tool reports unreadable paths itself; a preview must not + // second-guess it. + return "" + } + + // Evaluated against the running content exactly as handleEditFile does, so + // a preview never refuses a call the tool would accept: an earlier edit may + // legitimately remove a duplicate that made a later one ambiguous. + running := string(content) + for i, edit := range args.Edits { + if reason := filesystem.EditFailureReason(running, edit); reason != "" { + return styles.ErrorStyle.Render( + fmt.Sprintf("This call will be refused — Edit %d: %s", i+1, reason)) + } + running = strings.Replace(running, edit.OldText, edit.NewText, 1) + } + return "" +} + // countDiffLines returns the number of added and removed lines for the edit. // Results are cached per tool call since arguments are immutable. func countDiffLines(toolCall tools.ToolCall, _ types.ToolStatus) (added, removed int) { diff --git a/pkg/tui/components/tool/editfile/render_test.go b/pkg/tui/components/tool/editfile/render_test.go index e92cc32c9a..3fd561ef70 100644 --- a/pkg/tui/components/tool/editfile/render_test.go +++ b/pkg/tui/components/tool/editfile/render_test.go @@ -149,3 +149,84 @@ func TestRenderEditFile_MissingFileReturnsEmptyDiff(t *testing.T) { _ = renderEditFile(toolCall, 100, false, types.ToolStatusCompleted) }) } + +// An edit whose oldText matches more than once is refused by the tool, but +// strings.Replace(..., 1) always produces a first-occurrence diff — so without +// the preview check the user approves a change that never happens, having been +// shown a diff that was never on offer. +func TestRenderEditFile_ConfirmationPreviewsRefusal(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "conf.py") + + const original = "def dev():\n debug = True\n\ndef prod():\n debug = True\n" + require.NoError(t, os.WriteFile(path, []byte(original), 0o644)) + + render := func(t *testing.T, id string, edits []map[string]string, status types.ToolStatus) string { + t.Helper() + encoded, err := json.Marshal(map[string]any{"path": path, "edits": edits}) + require.NoError(t, err) + toolCall := tools.ToolCall{ + ID: id, + Function: tools.FunctionCall{Name: "edit_file", Arguments: string(encoded)}, + } + InvalidateCaches() + t.Cleanup(InvalidateCaches) + return ansi.Strip(renderEditFile(toolCall, 120, false, status)) + } + + t.Run("ambiguous edit shows the refusal instead of a diff", func(t *testing.T) { + out := render(t, "test-refusal-ambiguous", + []map[string]string{{"oldText": " debug = True", "newText": " debug = False"}}, + types.ToolStatusConfirmation) + + assert.Contains(t, out, "will be refused") + assert.Contains(t, out, "appears 2 times") + assert.NotContains(t, out, "debug = False", + "a diff the tool will not apply must not be shown") + }) + + t.Run("empty oldText shows the refusal", func(t *testing.T) { + out := render(t, "test-refusal-empty", + []map[string]string{{"oldText": "", "newText": "INJECTED"}}, + types.ToolStatusConfirmation) + + assert.Contains(t, out, "oldText must not be empty") + assert.NotContains(t, out, "INJECTED") + }) + + // Control: the preview must not refuse what the tool would accept. The + // first edit removes the duplicate that made the second one ambiguous, so + // the reason has to be evaluated against the running content. + t.Run("an earlier edit resolving a later ambiguity still previews", func(t *testing.T) { + out := render(t, "test-refusal-chained", + []map[string]string{ + {"oldText": "def dev():\n debug = True", "newText": "def dev():\n debug = None"}, + {"oldText": " debug = True", "newText": " debug = False"}, + }, + types.ToolStatusConfirmation) + + assert.NotContains(t, out, "will be refused") + assert.NotEmpty(t, out) + }) + + // Control: an unambiguous edit is unaffected. + t.Run("a unique edit previews normally", func(t *testing.T) { + out := render(t, "test-refusal-unique", + []map[string]string{{"oldText": "def prod():\n debug = True", "newText": "def prod():\n debug = False"}}, + types.ToolStatusConfirmation) + + assert.NotContains(t, out, "will be refused") + assert.Contains(t, out, "debug = False") + }) + + // Control: after execution the file on disk is the outcome; there is + // nothing left to predict and the diff must render as before. + t.Run("a completed call is never previewed as refused", func(t *testing.T) { + out := render(t, "test-refusal-completed", + []map[string]string{{"oldText": " debug = True", "newText": " debug = False"}}, + types.ToolStatusCompleted) + + assert.NotContains(t, out, "will be refused") + }) +}