laravel/ai: Keep CopyFile from copying directories
Contributed by yognevoy
Part of the ai v1.2.1 release
Pull request #1109 closes a small but meaningful gap in the filesystem tooling of laravel/ai: the CopyFile tool now verifies that its source path actually exists before attempting a copy, bringing it in line with MoveFile and DeleteFile. Through v1.2.0, CopyFile was the only one of the three tools without that guard, so it compensated with a hedged fallback message ("The source file may not exist"). The practical consequence was that a directory path slipped past validation and was handed straight to the storage adapter, where it could throw a low-level error or produce output the agent couldn't interpret. The change is behavior-visible, so it's worth a minute of attention before you upgrade.
What changed
The diff touches two files. In src/Tools/Filesystem/CopyFile.php, handle() now:
- resolves the disk once and casts the
from/torequest values to plain strings; - runs the shared
fileExists()check thatMoveFileandDeleteFilealready use, returningFile [{$from}] does not exist.when the source is missing; - keeps its
try/catcharound the actual$disk->copy()call, with the fallback message trimmed toUnable to copy [{$from}] to [{$to}].— the "source file may not exist" clause is gone, because that case is now handled before the copy is attempted.
$disk = $this->disk();
$from = (string) $request->string('from');
$to = (string) $request->string('to');
if (! $this->fileExists($disk, $from)) {
return "File [{$from}] does not exist.";
}
try {
$copied = $disk->copy($from, $to);
} catch (Throwable $throwable) {
return "Unable to copy [{$from}] to [{$to}]: {$throwable->getMessage()}";
}
tests/Feature/FilesystemToolsTest.php gains the matching coverage: the existing missing-source test now asserts the new message, a new test proves directories are refused, and a new test uses a Filesystem double to cover the previously untested false fallback path.
Why it matters
The PR title — "Keep CopyFile from copying directories" — points at the real bug. The probe behind fileExists() only recognizes files: on Flysystem-backed disks a directory is not a file, so it fails the check. In v1.2.0, handing CopyFile a directory bypassed validation entirely and reached $disk->copy() unguarded. Depending on the adapter, that either surfaced a raw Throwable message into the model's context via the catch-all, or produced a result that misled the agent about what had happened. Failing fast with the standard message removes both failure modes.
Equally important is consistency in what the agent sees. Copy, Move, and Delete now answer a missing source identically, so an agent — or your own branching logic over tool output — can rely on a single pattern instead of tool-specific wording. The hedged phrasing is also gone: does not exist is definitive, while Unable to copy is now reserved for genuine failures during the copy itself, such as an unwritable destination, which the new double-based test exercises.
Upgrade impact
The message for a missing source has changed:
// v1.2.0
Unable to copy [missing.txt] to [dst.txt]. The source file may not exist.
// after #1109
File [missing.txt] does not exist.
If any of your tests, log parsers, or prompt instructions match on the old sentence, update them.
Second, directory sources are now rejected deterministically. Workflows that attempted directory copies were already broken in practice, but they now fail immediately with File [photos] does not exist. and no side effects. If you need to copy a group of files, enumerate the paths on the disk and copy them individually — the tool intentionally stays file-scoped.
Beyond that, nothing changes: the constructor signature (new CopyFile('local')), the tool name, and its description are untouched, so existing agent configurations keep working. This is a drop-in upgrade from v1.2.0.
Seeing it in practice
The new directory test from the PR shows the guard end to end — the copy is never attempted, not merely reported as failed after the fact:
test('copy file does not copy directories', function (): void {
Storage::disk('local')->makeDirectory('photos');
$result = (new CopyFile('local'))->handle(new Request(['from' => 'photos', 'to' => 'archive/photos']));
expect($result)->toBe('File [photos] does not exist.');
Storage::disk('local')->assertExists('photos');
Storage::disk('local')->assertMissing('archive/photos');
});
Note the double-based test expects a call to size() on the source path, confirming the existence probe runs before copy() — that ordering is exactly what keeps directory paths out of the adapter.
Takeaways
CopyFilenow guards its source with the samefileExists()check asMoveFileandDeleteFile.- Missing sources return
File [x] does not exist.; the old "The source file may not exist." message is gone, so update any string matching. - Directories are rejected before any copy is attempted — no adapter errors, no partial results.
- Constructor, tool name, and description are unchanged; upgrading from v1.2.0 requires no config or prompt changes beyond message-string updates.