From c94f3c81fe52dadc5d24394aa651a3e8c0d48b52 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Mon, 3 Aug 2026 14:41:12 +0200 Subject: [PATCH] fix(filesystem): support ranged file reads Add ACP-aligned line and limit arguments to read_file and preserve the head of oversized read results with actionable continuation guidance. Keep tail truncation for shell and external tools. Fixes #3889 --- docs/configuration/hooks/index.md | 2 +- docs/tools/filesystem/index.md | 2 +- .../TestExec_Anthropic_ToolCall.yaml | 4 +- .../cassettes/TestExec_Gemini_ToolCall.yaml | 4 +- .../cassettes/TestExec_Mistral_ToolCall.yaml | 4 +- .../TestExec_OpenAI_HideToolCalls.yaml | 4 +- .../cassettes/TestExec_OpenAI_ToolCall.yaml | 4 +- pkg/acp/filesystem.go | 5 + pkg/acp/filesystem_test.go | 193 +++++++++++++++ pkg/hooks/builtins/builtins_test.go | 232 ++++++++++++++++++ .../builtins/limit_large_tool_results.go | 146 +++++++++-- pkg/tools/builtin/filesystem/filesystem.go | 121 ++++++++- .../builtin/filesystem/filesystem_test.go | 196 ++++++++++++++- 13 files changed, 887 insertions(+), 30 deletions(-) diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md index 846287c4a..34efe9d2b 100644 --- a/docs/configuration/hooks/index.md +++ b/docs/configuration/hooks/index.md @@ -216,7 +216,7 @@ Built-ins are typically zero-config and faster than equivalent shell hooks becau | `max_iterations` | `before_llm_call` | `[""]` (required) | Hard-stops the agent after `N` model calls. Stateless: the runtime supplies the iteration counter on every dispatch. | | `snapshot` | `session_start`, `turn_start`, `turn_end`, `pre_tool_use`, `post_tool_use`, `session_end` | _none_ | Records filesystem snapshots in a shadow git repo under the Docker Agent data directory. No-op outside git repos; respects the source repo's ignore rules and skips newly-added files larger than 2 MiB. | | `redact_secrets` | `pre_tool_use`, `before_llm_call`, `tool_response_transform` | _none_ | Scrubs detected secrets (API keys, tokens, private keys, …) out of tool call arguments, outgoing chat content, and tool output. The same builtin handles all three events and dispatches on the event name. Auto-registered on all three events by `redact_secrets: true` on the agent — see [`examples/redact_secrets_hooks.yaml`](https://github.com/docker/docker-agent/blob/main/examples/redact_secrets_hooks.yaml) for the manual wiring. | -| `limit_large_tool_results` | `tool_response_transform`, `session_end` | _none_ | **Always-on safety hook** — automatically injected by the runtime, no configuration required. When a tool result from the `filesystem`, `shell`, `mcp`, or `a2a` categories exceeds 2,000 lines or 50 KiB, the full payload is written to a per-session temp file and replaced in the conversation with a notice plus a bounded tail (last 2,000 lines, up to 50 KiB). The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. | +| `limit_large_tool_results` | `tool_response_transform`, `session_end` | _none_ | **Always-on safety hook** — automatically injected by the runtime, no configuration required. When a tool result from the `filesystem`, `shell`, `mcp`, or `a2a` categories exceeds 2,000 lines or 50 KiB, the full payload is written to a per-session temp file and replaced in the conversation with a notice plus a bounded excerpt (2,000 lines, up to 50 KiB): the tail for most tools, but the head for the built-in filesystem `read_file`, whose notice suggests a follow-up call with `line`/`limit` to continue reading. The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. | | `safer_shell` | `pre_tool_use` | _none_ | **Deprecated compatibility shim.** The runtime now classifies every shell command natively (`safe` / `destructive` / `unknown`) and gates it through the session's [safety mode](../permissions/index.md#safety-modes), so this builtin no longer emits verdicts. Pinned entries keep working as pure labellers that attach classification metadata (`safety_label`, `blast_radius`, `category`, `reason`) to the call. Filters by tool name internally (no-op for non-shell calls). | | `unload` | `on_agent_switch` | _none_ | POSTs `{"model": ""}` to each of the previous agent's DMR model endpoints (`/_unload` by default, overridable per-model via `unload_api`) to free the GPU/RAM the just-departing model was holding. Pure HTTP — reads the model snapshot the runtime ships on `on_agent_switch` and depends on no provider-specific runtime state. Non-DMR providers (OpenAI, Anthropic, …) are silently skipped, so cross-provider chains are safe. Errors are logged and swallowed; agent switching never blocks on a slow or unreachable engine (each call has a 10 s timeout). See [`examples/unload_on_switch.yaml`](https://github.com/docker/docker-agent/blob/main/examples/unload_on_switch.yaml). | diff --git a/docs/tools/filesystem/index.md b/docs/tools/filesystem/index.md index 8f0d49fe4..318287c86 100644 --- a/docs/tools/filesystem/index.md +++ b/docs/tools/filesystem/index.md @@ -17,7 +17,7 @@ The filesystem tool gives agents the ability to explore codebases, read and edit | Tool | Description | | ---------------------- | ------------------------------------------------------------------------- | -| `read_file` | Read the complete contents of a file | +| `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 | diff --git a/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml index 0bacb7330..99a67bf1f 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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":{"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the complete contents of a file from the file system. 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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":{"path":{"description":"File to read","type":"string"}},"required":["path"],"type":"object"},"name":"read_file","description":"Read the complete contents of a file from the file system. 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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 79e41f3a5..b955ef09b 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. 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":{"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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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"}}]}]} 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. 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":{"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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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"}}]}]} form: alt: - sse diff --git a/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml index 4fec71b7e..65fb6f20a 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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 f587daf8f..29fc44f7d 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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 0a822fba9..50930716b 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' 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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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 complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"}},"required":["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- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\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}' url: https://api.openai.com/v1/chat/completions method: POST response: diff --git a/pkg/acp/filesystem.go b/pkg/acp/filesystem.go index 01df32c31..de9878427 100644 --- a/pkg/acp/filesystem.go +++ b/pkg/acp/filesystem.go @@ -187,6 +187,9 @@ func (t *FilesystemToolset) handleReadFile(ctx context.Context, toolCall tools.T if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { return nil, fmt.Errorf("failed to parse arguments: %w", err) } + if err := filesystem.ValidateReadFileRange(args.Line, args.Limit); err != nil { + return tools.ResultError(fmt.Sprintf("Error: %s", err)), nil + } sessionID, ok := getSessionID(ctx) if !ok { @@ -204,6 +207,8 @@ func (t *FilesystemToolset) handleReadFile(ctx context.Context, toolCall tools.T resp, err := t.agent.conn.ReadTextFile(ctx, acp.ReadTextFileRequest{ SessionId: acp.SessionId(sessionID), Path: resolvedPath, + Line: args.Line, + Limit: args.Limit, }) if err != nil { return tools.ResultError(fmt.Sprintf("Error reading file: %s", err)), nil diff --git a/pkg/acp/filesystem_test.go b/pkg/acp/filesystem_test.go index bdd388ee2..9a77f0253 100644 --- a/pkg/acp/filesystem_test.go +++ b/pkg/acp/filesystem_test.go @@ -1,13 +1,23 @@ package acp import ( + "encoding/json" + "fmt" + "io" + "log/slog" "os" "path/filepath" "runtime" + "sync" "testing" + "time" + acpsdk "github.com/coder/acp-go-sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" ) func TestResolvePath(t *testing.T) { @@ -205,3 +215,186 @@ func TestResolvePath_NonExistentPathWithSymlinkAncestor(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "escapes the working directory") } + +// readTextFileResponder plays the ACP client side of the connection for +// fs/read_text_file requests: each decoded request is recorded and answered +// with the configured content. Any other JSON-RPC request fails the test +// immediately instead of deadlocking the sender. +type readTextFileResponder struct { + t *testing.T + peer io.Writer // write half of the connection's inbound peer pipe + content string + + mu sync.Mutex + requests []acpsdk.ReadTextFileRequest +} + +func (p *readTextFileResponder) Write(b []byte) (int, error) { + var msg struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(b, &msg); err != nil { + p.t.Errorf("peer received malformed JSON-RPC message %q: %v", b, err) + return 0, err + } + if len(msg.ID) == 0 || msg.Method != acpsdk.ClientMethodFsReadTextFile { + err := fmt.Errorf("peer cannot answer JSON-RPC message %q (id %s)", msg.Method, msg.ID) + p.t.Error(err) + return 0, err + } + + var req acpsdk.ReadTextFileRequest + if err := json.Unmarshal(msg.Params, &req); err != nil { + p.t.Errorf("peer failed to decode %s params: %v", msg.Method, err) + return 0, err + } + p.mu.Lock() + p.requests = append(p.requests, req) + p.mu.Unlock() + + response, err := json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result"` + }{JSONRPC: "2.0", ID: msg.ID, Result: acpsdk.ReadTextFileResponse{Content: p.content}}) + if err != nil { + return 0, fmt.Errorf("marshal response: %w", err) + } + if _, err := p.peer.Write(append(response, '\n')); err != nil { + return 0, err + } + return len(b), nil +} + +func (p *readTextFileResponder) recordedRequests() []acpsdk.ReadTextFileRequest { + p.mu.Lock() + defer p.mu.Unlock() + return append([]acpsdk.ReadTextFileRequest(nil), p.requests...) +} + +// TestFilesystemToolset_ReadFileForwardsLineRange verifies that the ACP +// read_file override forwards the optional line/limit arguments to the +// client's fs/read_text_file request and leaves them unset for a path-only +// call. +func TestFilesystemToolset_ReadFileForwardsLineRange(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + const sessionID = "read-range-session" + + acpAgent := &Agent{ + sessions: map[string]*Session{sessionID: {id: sessionID, workingDir: workingDir}}, + clientFS: acpsdk.FileSystemCapabilities{ReadTextFile: true}, + } + + peerReader, peerWriter := io.Pipe() + responder := &readTextFileResponder{t: t, peer: peerWriter, content: "two\nthree\n"} + conn := acpsdk.NewAgentSideConnection(acpAgent, responder, peerReader) + conn.SetLogger(slog.New(slog.DiscardHandler)) + acpAgent.SetAgentConnection(conn) + t.Cleanup(func() { + _ = peerWriter.Close() + select { + case <-conn.Done(): + case <-time.After(5 * time.Second): + t.Error("timed out waiting for ACP connection shutdown") + } + }) + + ts := NewFilesystemToolset(acpAgent, workingDir) + ctx := withSessionID(t.Context(), sessionID) + + result, err := ts.handleReadFile(ctx, tools.ToolCall{ + Function: tools.FunctionCall{ + Name: filesystem.ToolNameReadFile, + Arguments: `{"path": "notes.txt", "line": 2, "limit": 2}`, + }, + }, nil) + require.NoError(t, err) + require.False(t, result.IsError, result.Output) + assert.Equal(t, "two\nthree\n", result.Output) + + result, err = ts.handleReadFile(ctx, tools.ToolCall{ + Function: tools.FunctionCall{ + Name: filesystem.ToolNameReadFile, + Arguments: `{"path": "notes.txt"}`, + }, + }, nil) + require.NoError(t, err) + require.False(t, result.IsError, result.Output) + + reqs := responder.recordedRequests() + require.Len(t, reqs, 2) + + ranged := reqs[0] + assert.Equal(t, acpsdk.SessionId(sessionID), ranged.SessionId) + assert.Equal(t, "notes.txt", filepath.Base(ranged.Path)) + assert.True(t, filepath.IsAbs(ranged.Path), "ACP read requests must carry absolute paths") + require.NotNil(t, ranged.Line) + assert.Equal(t, 2, *ranged.Line) + require.NotNil(t, ranged.Limit) + assert.Equal(t, 2, *ranged.Limit) + + pathOnly := reqs[1] + assert.Nil(t, pathOnly.Line, "path-only read must not invent a line") + assert.Nil(t, pathOnly.Limit, "path-only read must not invent a limit") +} + +// TestFilesystemToolset_ReadFileRejectsInvalidRange verifies that the ACP +// read_file override applies the same line/limit contract as the builtin +// filesystem toolset: explicitly invalid values are rejected with a tool +// error before any fs/read_text_file request reaches the client. +func TestFilesystemToolset_ReadFileRejectsInvalidRange(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + const sessionID = "invalid-range-session" + + acpAgent := &Agent{ + sessions: map[string]*Session{sessionID: {id: sessionID, workingDir: workingDir}}, + clientFS: acpsdk.FileSystemCapabilities{ReadTextFile: true}, + } + + peerReader, peerWriter := io.Pipe() + responder := &readTextFileResponder{t: t, peer: peerWriter, content: "unreachable"} + conn := acpsdk.NewAgentSideConnection(acpAgent, responder, peerReader) + conn.SetLogger(slog.New(slog.DiscardHandler)) + acpAgent.SetAgentConnection(conn) + t.Cleanup(func() { + _ = peerWriter.Close() + select { + case <-conn.Done(): + case <-time.After(5 * time.Second): + t.Error("timed out waiting for ACP connection shutdown") + } + }) + + ts := NewFilesystemToolset(acpAgent, workingDir) + ctx := withSessionID(t.Context(), sessionID) + + for _, tc := range []struct { + name string + arguments string + wantErr string + }{ + {"zero line", `{"path": "notes.txt", "line": 0}`, "invalid line 0"}, + {"negative line", `{"path": "notes.txt", "line": -3}`, "invalid line -3"}, + {"zero limit", `{"path": "notes.txt", "limit": 0}`, "invalid limit 0"}, + {"negative limit", `{"path": "notes.txt", "limit": -1}`, "invalid limit -1"}, + } { + result, err := ts.handleReadFile(ctx, tools.ToolCall{ + Function: tools.FunctionCall{ + Name: filesystem.ToolNameReadFile, + Arguments: tc.arguments, + }, + }, nil) + require.NoError(t, err, tc.name) + require.NotNil(t, result, tc.name) + assert.True(t, result.IsError, tc.name) + assert.Contains(t, result.Output, tc.wantErr, tc.name) + } + + assert.Empty(t, responder.recordedRequests(), "invalid ranges must be rejected before any RPC") +} diff --git a/pkg/hooks/builtins/builtins_test.go b/pkg/hooks/builtins/builtins_test.go index 496a7863d..0fbebf45b 100644 --- a/pkg/hooks/builtins/builtins_test.go +++ b/pkg/hooks/builtins/builtins_test.go @@ -1,6 +1,7 @@ package builtins_test import ( + "fmt" "os" "path/filepath" "strconv" @@ -402,6 +403,227 @@ func TestLimitLargeToolResultsNoopsForSmallOutput(t *testing.T) { assert.Nil(t, out) } +// TestLimitLargeToolResultsReadFileKeepsHeadWithRangedReadNotice is a +// regression test for issue #3889: a truncated read_file result must keep +// the head of the file (front matter, imports, ...) instead of the tail, +// and the notice must tell the model how to fetch the rest with a ranged +// read_file call. +func TestLimitLargeToolResultsReadFileKeepsHeadWithRangedReadNotice(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + var b strings.Builder + for i := range 3000 { + b.WriteString(strings.Repeat("x", 600)) + b.WriteString(" line ") + b.WriteString(strconv.Itoa(i)) + b.WriteByte('\n') + } + original := b.String() + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "read-file-head-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "filesystem", + ToolName: "read_file", + ToolInput: map[string]any{"path": "big.txt"}, + ToolResponse: original, + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + + updated := *out.HookSpecificOutput.UpdatedToolResponse + assert.Contains(t, updated, "Tool call result was too large") + assert.Contains(t, updated, "Showing the first") + assert.Contains(t, updated, "call read_file again") + assert.Contains(t, updated, `"limit"`) + + // Head preserved, tail dropped — the opposite of the shell/tail case. + head := extractShownExcerpt(t, updated) + assert.True(t, strings.HasPrefix(head, strings.Repeat("x", 600)+" line 0\n"), + "head excerpt must start at the beginning of the result") + assert.NotContains(t, updated, " line 2999\n") + + // The suggested continuation line is 1 (start of an unranged read) + // plus the number of complete lines shown. + assert.Contains(t, updated, fmt.Sprintf(`"line": %d`, 1+strings.Count(head, "\n"))) + + // The full result is still spilled for recovery. + stored, err := os.ReadFile(extractLargeResultPath(t, updated)) + require.NoError(t, err) + assert.Equal(t, original, string(stored)) +} + +// TestLimitLargeToolResultsReadFileContinuationRespectsRequestedStartLine +// verifies that when the truncated read_file call itself used a line offset, +// the suggested continuation line is absolute in the file, not relative to +// the returned range. +func TestLimitLargeToolResultsReadFileContinuationRespectsRequestedStartLine(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + payload := strings.Repeat(strings.Repeat("y", 600)+"\n", 3000) + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "read-file-offset-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "filesystem", + ToolName: "read_file", + ToolInput: map[string]any{"path": "big.txt", "line": float64(101)}, + ToolResponse: payload, + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + + updated := *out.HookSpecificOutput.UpdatedToolResponse + head := extractShownExcerpt(t, updated) + assert.Contains(t, updated, fmt.Sprintf(`"line": %d`, 101+strings.Count(head, "\n"))) +} + +// TestLimitLargeToolResultsReadFileSingleLongLineDoesNotSuggestLoopingRead +// covers the pathological case for the head notice: the bounded head cuts +// inside a single line longer than the byte cap, so it contains no complete +// line and a "line"/"limit" continuation would restart at the same line +// forever. The notice must state that line-based continuation cannot +// advance within the first line instead of suggesting such a call. +func TestLimitLargeToolResultsReadFileSingleLongLineDoesNotSuggestLoopingRead(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + original := "line1-start " + strings.Repeat("z", maxToolCallResultBytesForTest+largeToolCallResultTailBytesForTest) + require.NotContains(t, original, "\n", "payload must be a single line") + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "read-file-long-line-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "filesystem", + ToolName: "read_file", + ToolInput: map[string]any{"path": "big.txt"}, + ToolResponse: original, + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + + updated := *out.HookSpecificOutput.UpdatedToolResponse + assert.Contains(t, updated, "Tool call result was too large") + assert.Contains(t, updated, fmt.Sprintf("Showing the first %d bytes", largeToolCallResultTailBytesForTest)) + + // No line-based continuation suggestion: any "line": N (including the + // misleading "line": 1) would re-read the same oversized line forever. + assert.NotContains(t, updated, `"line":`) + assert.NotContains(t, updated, "call read_file again") + assert.Contains(t, updated, "first line") + assert.Contains(t, updated, "cannot advance") + + // The head excerpt is retained verbatim and stays valid UTF-8. + head := extractShownExcerpt(t, updated) + assert.Equal(t, original[:largeToolCallResultTailBytesForTest], head) + assert.Equal(t, updated, strings.ToValidUTF8(updated, "")) + + // The full result is still spilled for recovery. + stored, err := os.ReadFile(extractLargeResultPath(t, updated)) + require.NoError(t, err) + assert.Equal(t, original, string(stored)) +} + +// TestLimitLargeToolResultsReadFileHeadPreservesUTF8 mirrors the tail +// UTF-8 test: a byte-boundary cut of the head must not leave a dangling +// partial rune. +func TestLimitLargeToolResultsReadFileHeadPreservesUTF8(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + payload := strings.Repeat("世", maxToolCallResultBytesForTest/len("世")+largeToolCallResultTailBytesForTest/len("世")+10) + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "read-file-utf8-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "filesystem", + ToolName: "read_file", + ToolResponse: payload, + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + updated := *out.HookSpecificOutput.UpdatedToolResponse + assert.Contains(t, updated, "世") + assert.Equal(t, updated, strings.ToValidUTF8(updated, "")) +} + +// TestLimitLargeToolResultsShellKeepsTail pins that head-first truncation is +// scoped to read_file: shell output keeps its tail, where exit diagnostics +// live. +func TestLimitLargeToolResultsShellKeepsTail(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + var b strings.Builder + for i := range 3000 { + b.WriteString(strings.Repeat("x", 600)) + b.WriteString(" line ") + b.WriteString(strconv.Itoa(i)) + b.WriteByte('\n') + } + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "shell-tail-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "shell", + ToolName: "shell", + ToolResponse: b.String(), + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + + updated := *out.HookSpecificOutput.UpdatedToolResponse + assert.Contains(t, updated, "Showing the last") + assert.Contains(t, updated, " line 2999\n") + assert.NotContains(t, updated, " line 0\n") +} + +// TestLimitLargeToolResultsMCPReadFileKeepsTail pins that head-first +// truncation requires the built-in filesystem category, not just the +// read_file name: an mcp/a2a tool that happens to be called read_file has +// no known line/limit contract, so it keeps the generic tail notice with +// no local ranged-read advice. +func TestLimitLargeToolResultsMCPReadFileKeepsTail(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + var b strings.Builder + for i := range 3000 { + b.WriteString(strings.Repeat("x", 600)) + b.WriteString(" line ") + b.WriteString(strconv.Itoa(i)) + b.WriteByte('\n') + } + + fn := lookup(t, builtins.LimitLargeToolResults) + out, err := fn(t.Context(), &hooks.Input{ + SessionID: "mcp-read-file-session", + HookEventName: hooks.EventToolResponseTransform, + ToolCategory: "mcp", + ToolName: "read_file", + ToolResponse: b.String(), + }, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.HookSpecificOutput.UpdatedToolResponse) + + updated := *out.HookSpecificOutput.UpdatedToolResponse + assert.Contains(t, updated, "Showing the last") + assert.Contains(t, updated, " line 2999\n") + assert.NotContains(t, updated, " line 0\n") + assert.NotContains(t, updated, "Showing the first") + assert.NotContains(t, updated, "call read_file again") + assert.NotContains(t, updated, `"line":`) +} + func extractLargeResultPath(t *testing.T, response string) string { t.Helper() const marker = "The full result is available in a file: " @@ -413,6 +635,16 @@ func extractLargeResultPath(t *testing.T, response string) string { return response[pathStart : pathStart+pathEnd] } +// extractShownExcerpt returns the excerpt following the limiter's notice, +// which ends with ":\n\n" in both the head and tail message formats. +func extractShownExcerpt(t *testing.T, response string) string { + t.Helper() + const sep = ":\n\n" + idx := strings.Index(response, sep) + require.NotEqual(t, -1, idx) + return response[idx+len(sep):] +} + // TestRegisterSnapshotInstallsBuiltin verifies that the dedicated // snapshot entry point installs the snapshot builtin and returns a // controller wired up to the registered hook. diff --git a/pkg/hooks/builtins/limit_large_tool_results.go b/pkg/hooks/builtins/limit_large_tool_results.go index faf23646a..e515e076a 100644 --- a/pkg/hooks/builtins/limit_large_tool_results.go +++ b/pkg/hooks/builtins/limit_large_tool_results.go @@ -16,7 +16,10 @@ import ( // LimitLargeToolResults is the registered name of the builtin // tool_response_transform hook that stores oversized tool results in a per-session -// temp directory and returns a bounded tail plus a notice for the conversation. +// temp directory and returns a bounded excerpt plus a notice for the +// conversation: the head for the built-in filesystem read_file (whose +// line/limit arguments let the model fetch later ranges), the tail for +// everything else. const LimitLargeToolResults = "limit_large_tool_results" const ( @@ -33,12 +36,26 @@ const ( // already truncate their output). Internal toolsets (memory, plan, tasks, // think, ...) return bounded, structured results and are left untouched. var largeResultCategories = map[string]bool{ - "filesystem": true, - "shell": true, - "mcp": true, - "a2a": true, + filesystemToolCategory: true, + "shell": true, + "mcp": true, + "a2a": true, } +// filesystemToolCategory is the category of the built-in filesystem toolset. +const filesystemToolCategory = "filesystem" + +// readFileToolName mirrors the filesystem toolset's read_file tool name +// without importing the whole toolset package. read_file results are +// truncated head-first because the beginning of a file (front matter, +// imports, config preamble) is usually what the model needs first, and the +// tool's line/limit arguments make the rest reachable. Matched by +// ToolCategory and ToolName together: other filesystem tools +// (read_multiple_files, search_files_content, ...) and mcp/a2a tools that +// merely share the read_file name have no known line/limit contract, so +// they keep tail truncation. +const readFileToolName = "read_file" + func limitLargeToolResults(ctx context.Context, in *hooks.Input, _ []string) (*hooks.Output, error) { if in == nil { return nil, nil @@ -71,16 +88,21 @@ func limitLargeToolResponse(ctx context.Context, in *hooks.Input) (*hooks.Output return nil, nil } - tail := tailLargeToolResult(payload) - updated := fmt.Sprintf( - "Tool call result was too large (%d bytes; limit %d bytes). The full result is available in a file: %s\n\nShowing the last %d lines (up to %d bytes):\n\n%s", - len(payload), - maxToolCallResultBytes, - path, - largeToolCallResultTailLines, - largeToolCallResultTailBytes, - tail, - ) + var updated string + if in.ToolCategory == filesystemToolCategory && in.ToolName == readFileToolName { + updated = readFileHeadNotice(in.ToolInput, payload, path) + } else { + tail := tailLargeToolResult(payload) + updated = fmt.Sprintf( + "Tool call result was too large (%d bytes; limit %d bytes). The full result is available in a file: %s\n\nShowing the last %d lines (up to %d bytes):\n\n%s", + len(payload), + maxToolCallResultBytes, + path, + largeToolCallResultTailLines, + largeToolCallResultTailBytes, + tail, + ) + } return &hooks.Output{ HookSpecificOutput: &hooks.HookSpecificOutput{ @@ -90,6 +112,38 @@ func limitLargeToolResponse(ctx context.Context, in *hooks.Input) (*hooks.Output }, nil } +// readFileHeadNotice renders the oversized-result notice for the built-in +// filesystem read_file: the head excerpt plus continuation advice. When the +// head contains at least one complete line, the notice gives the line/limit +// arguments that fetch the next range. A head without a single newline +// means the first line alone exceeds the byte cap: a line-based call cannot +// advance within that line (and rereading the spill file with read_file +// would be capped by this hook again), so the notice states that limitation +// instead of suggesting a call that would loop on the same line. +func readFileHeadNotice(toolInput map[string]any, payload, path string) string { + head := headLargeToolResult(payload) + if !strings.Contains(head, "\n") { + return fmt.Sprintf( + "Tool call result was too large (%d bytes; limit %d bytes). The full result is available in a file: %s\n\nShowing the first %d bytes. The first line of this result alone exceeds the excerpt limit, so read_file's line-based \"line\"/\"limit\" arguments cannot advance within it, and reading the file above with read_file would be truncated the same way. To read beyond this excerpt, use a tool or command that can read byte ranges (for example a shell command) on that file:\n\n%s", + len(payload), + maxToolCallResultBytes, + path, + len(head), + head, + ) + } + return fmt.Sprintf( + "Tool call result was too large (%d bytes; limit %d bytes). The full result is available in a file: %s\n\nShowing the first %d lines (up to %d bytes). To continue reading, call read_file again with the same path plus \"line\": %d (1-based start line) and a \"limit\" (maximum number of lines):\n\n%s", + len(payload), + maxToolCallResultBytes, + path, + largeToolCallResultTailLines, + largeToolCallResultTailBytes, + nextReadFileLine(toolInput, head), + head, + ) +} + func largeToolResultLimitExceeded(payload string) bool { return len(payload) > maxToolCallResultBytes || lineCount(payload) > largeToolCallResultTailLines } @@ -145,6 +199,30 @@ func tailLargeToolResult(payload string) string { return string(tail) } +func headLargeToolResult(payload string) string { + head := firstLines([]byte(payload), largeToolCallResultTailLines) + if len(head) > largeToolCallResultTailBytes { + head = trimToRuneEnd(head[:largeToolCallResultTailBytes]) + } + return string(head) +} + +// nextReadFileLine computes the 1-based file line to pass as read_file's +// line argument to continue past the truncated head: the truncated call's +// own start line (1 unless it already used line) plus the number of +// complete lines shown. A byte-capped partial trailing line is deliberately +// re-read in full by the follow-up call. Only meaningful for heads with at +// least one complete line: with none the sum repeats the same start line, +// which readFileHeadNotice reports as un-advanceable instead of suggesting. +func nextReadFileLine(toolInput map[string]any, head string) int { + start := 1 + // Tool arguments arrive JSON-decoded, so numbers are float64. + if v, ok := toolInput["line"].(float64); ok && v >= 1 { + start = int(v) + } + return start + strings.Count(head, "\n") +} + func trimToRuneStart(data []byte) []byte { for len(data) > 0 && !utf8.RuneStart(data[0]) { data = data[1:] @@ -152,6 +230,24 @@ func trimToRuneStart(data []byte) []byte { return data } +// trimToRuneEnd drops a trailing incomplete UTF-8 sequence produced by a +// byte-boundary cut so the head stays valid UTF-8, mirroring +// trimToRuneStart for tails. Bytes that were already invalid before the cut +// are left alone. +func trimToRuneEnd(data []byte) []byte { + end := len(data) + for i := end - 1; i >= 0 && i > end-utf8.UTFMax; i-- { + if !utf8.RuneStart(data[i]) { + continue + } + if !utf8.FullRune(data[i:end]) { + return data[:i] + } + break + } + return data +} + func lastLines(data []byte, limit int) []byte { if limit <= 0 || len(data) == 0 { return data @@ -169,3 +265,23 @@ func lastLines(data []byte, limit int) []byte { } return data } + +// firstLines returns the first limit lines of data, line terminators +// included; data with at most limit lines is returned unchanged. +func firstLines(data []byte, limit int) []byte { + if limit <= 0 || len(data) == 0 { + return data + } + + lines := 0 + for i, b := range data { + if b != '\n' { + continue + } + lines++ + if lines >= limit { + return data[:i+1] + } + } + return data +} diff --git a/pkg/tools/builtin/filesystem/filesystem.go b/pkg/tools/builtin/filesystem/filesystem.go index e61a47553..ad28b2d20 100644 --- a/pkg/tools/builtin/filesystem/filesystem.go +++ b/pkg/tools/builtin/filesystem/filesystem.go @@ -1,6 +1,7 @@ package filesystem import ( + "bufio" "bytes" "context" "encoding/base64" @@ -319,6 +320,10 @@ type DirectoryTreeMeta struct { type ReadFileArgs struct { Path string `json:"path" jsonschema:"File to read"` + // Line and Limit are pointers so an omitted value is distinguishable + // from an explicit (invalid) zero. + Line *int `json:"line,omitempty" jsonschema:"1-based line number to start reading from (text files only; defaults to the first line)"` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of lines to read (text files only; defaults to reading through the end of the file)"` } type ReadFileMeta struct { @@ -507,7 +512,7 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { { Name: ToolNameReadFile, Category: "filesystem", - Description: "Read the complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.", + 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: tools.MustSchemaFor[ReadFileArgs](), OutputSchema: tools.MustSchemaFor[string](), Handler: tools.NewHandler(t.handleReadFile), @@ -999,6 +1004,14 @@ func (t *ToolSet) handleListDirectory(ctx context.Context, args ListDirectoryArg func (t *ToolSet) handleReadFile(ctx context.Context, args ReadFileArgs) (*tools.ToolCallResult, error) { annotateFilesystemSpan(ctx, "read_file", args.Path) + if err := ValidateReadFileRange(args.Line, args.Limit); err != nil { + return &tools.ToolCallResult{ + Output: err.Error(), + IsError: true, + Meta: ReadFileMeta{Path: args.Path, Error: err.Error()}, + }, nil + } + resolvedPath, err := t.resolveAndCheckPath(args.Path) if err != nil { return &tools.ToolCallResult{ @@ -1029,9 +1042,37 @@ func (t *ToolSet) handleReadFile(ctx context.Context, args ReadFileArgs) (*tools // Only check for image files on regular files (not directories, etc.) if info.Mode().IsRegular() && chat.IsImageFile(resolvedPath) { + // Byte-exact line ranges are meaningless for base64 image content; + // reject instead of silently returning the whole image. + if args.Line != nil || args.Limit != nil { + errMsg := "line/limit are only supported for text files; read the image without line/limit" + return &tools.ToolCallResult{ + Output: errMsg, + IsError: true, + Meta: ReadFileMeta{Path: args.Path, Error: errMsg}, + }, nil + } return t.readImageFile(resolvedPath, args.Path) } + if args.Line != nil || args.Limit != nil { + selected, totalLines, err := t.readFileLineRange(resolvedPath, args.Line, args.Limit) + if err != nil { + return &tools.ToolCallResult{ + Output: err.Error(), + IsError: true, + Meta: ReadFileMeta{Path: args.Path, Error: err.Error()}, + }, nil + } + return &tools.ToolCallResult{ + Output: string(selected), + Meta: ReadFileMeta{ + Path: args.Path, + LineCount: totalLines, + }, + }, nil + } + content, err := t.readFile(resolvedPath) if err != nil { return &tools.ToolCallResult{ @@ -1048,11 +1089,89 @@ func (t *ToolSet) handleReadFile(ctx context.Context, args ReadFileArgs) (*tools return &tools.ToolCallResult{ Output: text, Meta: ReadFileMeta{ + Path: args.Path, LineCount: strings.Count(text, "\n") + 1, }, }, nil } +// ValidateReadFileRange rejects explicitly supplied non-positive range +// values; nil means "not provided" and is always valid. It is exported so +// alternative read_file implementations (e.g. the ACP override) apply the +// same contract before doing any I/O. +func ValidateReadFileRange(line, limit *int) error { + if line != nil && *line < 1 { + return fmt.Errorf("invalid line %d: line numbers are 1-based and must be >= 1", *line) + } + if limit != nil && *limit < 1 { + return fmt.Errorf("invalid limit %d: limit must be >= 1", *limit) + } + return nil +} + +// readFileLineRange streams the file at resolved and returns the exact bytes +// of the requested 1-based line range (line terminators included, so a final +// newline is preserved when present) plus the file's total line count +// (newline count + 1, matching the full-read metadata). Only the selected +// lines are held in memory. A nil line starts at the first line, a nil limit +// reads through EOF, and a start past the last line yields empty content. +// Like readFileHeader, it opens the file through rootedAccess so allow-listed +// paths keep their TOCTOU protection. +func (t *ToolSet) readFileLineRange(resolved string, line, limit *int) ([]byte, int, error) { + start := 1 + if line != nil { + start = *line + } + maxLines := -1 // -1: read through EOF + if limit != nil { + maxLines = *limit + } + + root, rel, err := t.rootedAccess(resolved) + if err != nil { + return nil, 0, err + } + + var file *os.File + if root != nil { + file, err = root.Open(rel) + } else { + file, err = os.Open(resolved) + } + if err != nil { + return nil, 0, err + } + defer file.Close() + + // bufio.Reader.ReadSlice instead of a Scanner so arbitrarily long lines + // never hit a token limit: a long line simply arrives in multiple chunks + // sharing one line number, and chunks of unselected lines are discarded + // without being retained. + reader := bufio.NewReader(file) + var selected []byte + newlines := 0 + current := 1 + for { + chunk, readErr := reader.ReadSlice('\n') + if len(chunk) > 0 { + if current >= start && (maxLines < 0 || current-start < maxLines) { + selected = append(selected, chunk...) + } + if chunk[len(chunk)-1] == '\n' { + newlines++ + current++ + } + } + if readErr == nil || errors.Is(readErr, bufio.ErrBufferFull) { + continue + } + if errors.Is(readErr, io.EOF) { + return selected, newlines + 1, nil + } + return nil, 0, readErr + } +} + // readImageFile reads an image file and returns it as base64-encoded image content. // The caller must ensure the file exists (e.g. via os.Stat) before calling this method. func (t *ToolSet) readImageFile(resolvedPath, originalPath string) (*tools.ToolCallResult, error) { diff --git a/pkg/tools/builtin/filesystem/filesystem_test.go b/pkg/tools/builtin/filesystem/filesystem_test.go index cd45a55fe..8180d9955 100644 --- a/pkg/tools/builtin/filesystem/filesystem_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_test.go @@ -9,11 +9,14 @@ import ( "image/png" "os" "path/filepath" + "slices" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" ) // initGitRepo initializes a git repository in the given directory @@ -106,7 +109,7 @@ func TestFilesystemTool_ReadFile_TildePath(t *testing.T) { require.NoError(t, err) assert.False(t, result.IsError) assert.Equal(t, content, result.Output) - assert.Equal(t, ReadFileMeta{LineCount: 1}, result.Meta) + assert.Equal(t, ReadFileMeta{Path: "~/note.txt", LineCount: 1}, result.Meta) } func TestFilesystemTool_WriteFile(t *testing.T) { @@ -169,7 +172,7 @@ func TestFilesystemTool_ReadFile(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, content, result.Output) - assert.Equal(t, ReadFileMeta{LineCount: 1}, result.Meta) + assert.Equal(t, ReadFileMeta{Path: testFile, LineCount: 1}, result.Meta) result, err = tool.handleReadFile(t.Context(), ReadFileArgs{ Path: "nonexistent.txt", @@ -178,6 +181,195 @@ func TestFilesystemTool_ReadFile(t *testing.T) { assert.Equal(t, "not found", result.Output) } +// TestFilesystemTool_ReadFile_LineRange is a regression test for issue +// #3889: read_file must support optional ACP-style line/limit arguments so +// large files stay reachable in ranged chunks, while a path-only call keeps +// returning the exact full content. +func TestFilesystemTool_ReadFile_LineRange(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + + // Five lines with a final newline, and three lines without one. + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "lines.txt"), []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "nonewline.txt"), []byte("alpha\nbeta\ngamma"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "empty.txt"), nil, 0o644)) + + tests := []struct { + name string + args ReadFileArgs + want string + wantLineCount int + }{ + { + name: "path only returns exact full content", + args: ReadFileArgs{Path: "lines.txt"}, + want: "one\ntwo\nthree\nfour\nfive\n", + wantLineCount: 6, + }, + { + name: "head range", + args: ReadFileArgs{Path: "lines.txt", Line: new(1), Limit: new(2)}, + want: "one\ntwo\n", + wantLineCount: 6, + }, + { + name: "middle range", + args: ReadFileArgs{Path: "lines.txt", Line: new(2), Limit: new(2)}, + want: "two\nthree\n", + wantLineCount: 6, + }, + { + name: "line without limit reads through EOF", + args: ReadFileArgs{Path: "lines.txt", Line: new(4)}, + want: "four\nfive\n", + wantLineCount: 6, + }, + { + name: "limit without line starts at the first line", + args: ReadFileArgs{Path: "lines.txt", Limit: new(3)}, + want: "one\ntwo\nthree\n", + wantLineCount: 6, + }, + { + name: "limit past EOF stops at EOF", + args: ReadFileArgs{Path: "nonewline.txt", Line: new(2), Limit: new(99)}, + want: "beta\ngamma", + wantLineCount: 3, + }, + { + name: "last line without final newline is byte-exact", + args: ReadFileArgs{Path: "nonewline.txt", Line: new(3), Limit: new(1)}, + want: "gamma", + wantLineCount: 3, + }, + { + name: "start beyond EOF returns empty output", + args: ReadFileArgs{Path: "nonewline.txt", Line: new(42)}, + want: "", + wantLineCount: 3, + }, + { + name: "empty file with range returns empty output", + args: ReadFileArgs{Path: "empty.txt", Line: new(1), Limit: new(1)}, + want: "", + wantLineCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tool.handleReadFile(t.Context(), tt.args) + require.NoError(t, err) + assert.False(t, result.IsError, "unexpected tool error: %s", result.Output) + assert.Equal(t, tt.want, result.Output) + // LineCount stays the total file line count, not the returned range. + assert.Equal(t, ReadFileMeta{Path: tt.args.Path, LineCount: tt.wantLineCount}, result.Meta) + }) + } +} + +// TestFilesystemTool_ReadFile_LineRangeInvalid pins that explicitly invalid +// range values produce a tool error result (never a panic) and that the file +// is not read. +func TestFilesystemTool_ReadFile_LineRangeInvalid(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "lines.txt"), []byte("one\ntwo\n"), 0o644)) + + tests := []struct { + name string + args ReadFileArgs + wantMsg string + }{ + {"zero line", ReadFileArgs{Path: "lines.txt", Line: new(0)}, "invalid line 0"}, + {"negative line", ReadFileArgs{Path: "lines.txt", Line: new(-3)}, "invalid line -3"}, + {"zero limit", ReadFileArgs{Path: "lines.txt", Limit: new(0)}, "invalid limit 0"}, + {"negative limit", ReadFileArgs{Path: "lines.txt", Limit: new(-1)}, "invalid limit -1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tool.handleReadFile(t.Context(), tt.args) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, tt.wantMsg) + }) + } +} + +// TestFilesystemTool_ReadFile_LineRangeLongLine exercises the +// bufio.ErrBufferFull path: a line longer than the default reader buffer +// must be returned byte-exact and counted as a single line. +func TestFilesystemTool_ReadFile_LineRangeLongLine(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + + longLine := strings.Repeat("x", 20000) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "long.txt"), []byte("short\n"+longLine+"\nend\n"), 0o644)) + + result, err := tool.handleReadFile(t.Context(), ReadFileArgs{Path: "long.txt", Line: new(2), Limit: new(1)}) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Equal(t, longLine+"\n", result.Output) + assert.Equal(t, ReadFileMeta{Path: "long.txt", LineCount: 4}, result.Meta) +} + +// TestFilesystemTool_ReadFile_LineRangeImageRejected pins that images are +// never silently ranged: read_file with line/limit on an image is a clear +// tool error. +func TestFilesystemTool_ReadFile_LineRangeImageRejected(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + tool := New(tmpDir) + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.png"), createTestPNG(t, 10, 10), 0o644)) + + result, err := tool.handleReadFile(t.Context(), ReadFileArgs{Path: "test.png", Line: new(1), Limit: new(10)}) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "line/limit are only supported for text files") + assert.Empty(t, result.Images) +} + +// TestFilesystemTool_ReadFileSchemaExposesLineRange pins the tool contract: +// the read_file parameter schema must advertise the optional line and limit +// arguments while keeping only path required, and the description must not +// promise complete contents only. +func TestFilesystemTool_ReadFileSchemaExposesLineRange(t *testing.T) { + t.Parallel() + tool := New(t.TempDir()) + + all, err := tool.Tools(t.Context()) + require.NoError(t, err) + + idx := slices.IndexFunc(all, func(tl tools.Tool) bool { return tl.Name == ToolNameReadFile }) + require.NotEqual(t, -1, idx, "read_file tool must exist") + readFile := all[idx] + + assert.Contains(t, readFile.Description, "line") + assert.Contains(t, readFile.Description, "limit") + + data, err := json.Marshal(readFile.Parameters) + require.NoError(t, err) + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + } + require.NoError(t, json.Unmarshal(data, &schema)) + assert.Contains(t, schema.Properties, "line") + assert.Contains(t, schema.Properties, "limit") + assert.Contains(t, schema.Properties, "path") + assert.Equal(t, []string{"path"}, schema.Required) +} + func TestFilesystemTool_ReadImageFile(t *testing.T) { t.Parallel() tmpDir := t.TempDir()