Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/runtimeuse-client-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "runtimeuse-client"
version = "0.14.2"
version = "0.15.0"
description = "Client library for AI agent runtime communication over WebSocket"
readme = "README.md"
license = {"text" = "FSL"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def _build_invocation(prompt: str, options: QueryOptions) -> InvocationMessage:
agent_env=options.agent_env,
secrets_to_redact=options.secrets_to_redact,
artifacts_dirs=options.artifacts_dirs or None,
artifacts_ignore_content=options.artifacts_ignore_content,
pre_agent_invocation_commands=options.pre_agent_invocation_commands,
post_agent_invocation_commands=options.post_agent_invocation_commands,
pre_agent_downloadables=options.pre_agent_downloadables,
Expand All @@ -58,6 +59,7 @@ def _build_command_execution(
secrets_to_redact=options.secrets_to_redact,
commands=commands,
artifacts_dirs=options.artifacts_dirs or None,
artifacts_ignore_content=options.artifacts_ignore_content,
pre_execution_downloadables=options.pre_execution_downloadables,
)

Expand Down
14 changes: 14 additions & 0 deletions packages/runtimeuse-client-python/src/runtimeuse_client/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class InvocationMessage(BaseModel):
output_format_json_schema_str: str | None = None
secrets_to_redact: list[str] = Field(default_factory=list)
artifacts_dirs: list[str] | None = None
artifacts_ignore_content: str | None = None
pre_agent_invocation_commands: list[CommandInterface] | None = None
post_agent_invocation_commands: list[CommandInterface] | None = None
model: str
Expand Down Expand Up @@ -140,6 +141,7 @@ class CommandExecutionMessage(BaseModel):
secrets_to_redact: list[str] = Field(default_factory=list)
commands: list[CommandInterface]
artifacts_dirs: list[str] | None = None
artifacts_ignore_content: str | None = None
pre_execution_downloadables: (
list[RuntimeEnvironmentDownloadableInterface] | None
) = None
Expand Down Expand Up @@ -213,6 +215,12 @@ class QueryOptions:
#: Each directory is watched independently and may carry its own
#: ``.artifactignore``. An empty list is treated the same as ``None``.
artifacts_dirs: list[str] | None = None
#: Gitignore-format text applied as the ignore patterns for every directory
#: in ``artifacts_dirs``. When set, takes precedence over any
#: ``.artifactignore`` file present at the watched directory in the runtime
#: environment. Use this when the watched directory may not exist on disk
#: at the time the runtime starts watching it.
artifacts_ignore_content: str | None = None
#: Commands to run in the runtime environment before the agent starts.
pre_agent_invocation_commands: list[CommandInterface] | None = None
#: Commands to run in the runtime environment after the agent finishes.
Expand Down Expand Up @@ -255,6 +263,12 @@ class ExecuteCommandsOptions:
#: Each directory is watched independently and may carry its own
#: ``.artifactignore``. An empty list is treated the same as ``None``.
artifacts_dirs: list[str] | None = None
#: Gitignore-format text applied as the ignore patterns for every directory
#: in ``artifacts_dirs``. When set, takes precedence over any
#: ``.artifactignore`` file present at the watched directory in the runtime
#: environment. Use this when the watched directory may not exist on disk
#: at the time the runtime starts watching it.
artifacts_ignore_content: str | None = None
#: Files to download into the runtime environment before commands run.
pre_execution_downloadables: (
list[RuntimeEnvironmentDownloadableInterface] | None
Expand Down
81 changes: 81 additions & 0 deletions packages/runtimeuse-client-python/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,59 @@ async def test_artifact_upload_ignored_without_callback(
options=query_options,
)

@pytest.mark.asyncio
async def test_artifacts_ignore_content_forwarded_to_invocation(
self, fake_transport, make_query_options
):
transport, client = fake_transport([TEXT_RESULT_MSG])

async def _on_artifact(_req):
return ArtifactUploadResult(
presigned_url="https://s3.example.com/x", content_type="text/plain"
)

await client.query(
prompt=DEFAULT_PROMPT,
options=make_query_options(
artifacts_dirs=["/tmp/artifacts"],
artifacts_ignore_content="*.log\nnode_modules/\n",
on_artifact_upload_request=_on_artifact,
),
)

invocation_msgs = [
m for m in transport.sent if m.get("message_type") == "invocation_message"
]
assert len(invocation_msgs) == 1
assert (
invocation_msgs[0]["artifacts_ignore_content"]
== "*.log\nnode_modules/\n"
)

@pytest.mark.asyncio
async def test_artifacts_ignore_content_defaults_to_none(
self, fake_transport, make_query_options
):
transport, client = fake_transport([TEXT_RESULT_MSG])

async def _on_artifact(_req):
return ArtifactUploadResult(
presigned_url="https://s3.example.com/x", content_type="text/plain"
)

await client.query(
prompt=DEFAULT_PROMPT,
options=make_query_options(
artifacts_dirs=["/tmp/artifacts"],
on_artifact_upload_request=_on_artifact,
),
)

invocation_msgs = [
m for m in transport.sent if m.get("message_type") == "invocation_message"
]
assert invocation_msgs[0]["artifacts_ignore_content"] is None


# ---------------------------------------------------------------------------
# Cancellation
Expand Down Expand Up @@ -1085,6 +1138,34 @@ async def _on_artifact(_req):
assert cmd_msgs[0]["artifacts_dirs"] == ["/tmp/one", "/tmp/two"]
assert "artifacts_dir" not in cmd_msgs[0]

@pytest.mark.asyncio
async def test_artifacts_ignore_content_forwarded_to_command_execution(
self, fake_transport, make_execute_commands_options
):
transport, client = fake_transport([COMMAND_RESULT_MSG])

async def _on_artifact(_req):
return ArtifactUploadResult(
presigned_url="https://s3.example.com/x", content_type="text/plain"
)

await client.execute_commands(
commands=[CommandInterface(command="echo hello")],
options=make_execute_commands_options(
artifacts_dirs=["/tmp/artifacts"],
artifacts_ignore_content="*.tmp\n",
on_artifact_upload_request=_on_artifact,
),
)

cmd_msgs = [
m
for m in transport.sent
if m.get("message_type") == "command_execution_message"
]
assert len(cmd_msgs) == 1
assert cmd_msgs[0]["artifacts_ignore_content"] == "*.tmp\n"

@pytest.mark.asyncio
async def test_command_env_forwarded(
self, fake_transport, make_execute_commands_options
Expand Down
2 changes: 1 addition & 1 deletion packages/runtimeuse-client-python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/runtimeuse/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/runtimeuse/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "runtimeuse",
"version": "0.14.2",
"version": "0.15.0",
"description": "AI agent runtime with WebSocket protocol, artifact handling, and secret management",
"license": "FSL",
"type": "module",
Expand Down
98 changes: 96 additions & 2 deletions packages/runtimeuse/src/artifact-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,26 @@ import fs from "fs";
import chokidar from "chokidar";
import {
ArtifactManager,
type AddDirectoryOptions,
type ArtifactManagerConfig,
} from "./artifact-manager.js";
import { UploadTracker } from "./upload-tracker.js";
import { uploadFile } from "./storage.js";
import type { Logger } from "./logger.js";

function makeLogger(): Logger {
return {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}

function createManager(
overrides: Partial<ArtifactManagerConfig> = {},
artifactsDir: string | null = "/tmp/artifacts",
addOptions: AddDirectoryOptions = {},
) {
const send = vi.fn();
const uploadTracker = new UploadTracker();
Expand All @@ -47,9 +59,11 @@ function createManager(
send,
...overrides,
};
const logger = makeLogger();
const manager = new ArtifactManager(config);
if (artifactsDir) manager.addDirectory(artifactsDir);
return { manager, send, uploadTracker };
manager.setLogger(logger);
if (artifactsDir) manager.addDirectory(artifactsDir, addOptions);
return { manager, send, uploadTracker, logger };
}

function getHandler(event: string) {
Expand Down Expand Up @@ -321,4 +335,84 @@ describe("ArtifactManager", () => {
expect(send).not.toHaveBeenCalled();
});
});

describe("ignoreContent option", () => {
it("applies inline ignore patterns from ignoreContent", () => {
const { send } = createManager({}, "/tmp/artifacts", {
ignoreContent: "*.log\n",
});
getHandler("add")("/tmp/artifacts/debug.log", fileStats());
expect(send).not.toHaveBeenCalled();
});

it("does not read .artifactignore from disk when ignoreContent is provided", () => {
createManager({}, "/tmp/artifacts", { ignoreContent: "*.log\n" });
expect(fs.existsSync).not.toHaveBeenCalled();
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("ignoreContent wins over an inline blob even with a .artifactignore on disk", () => {
const { send } = createManager({}, "/tmp/artifacts", {
ignoreContent: "*.log\n",
});

// The inline blob declares only *.log, so a file matching the on-disk
// .artifactignore (*.png) should still be uploaded.
getHandler("add")("/tmp/artifacts/screenshot.png", fileStats());
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ filename: "screenshot.png" }),
);

send.mockClear();
// *.log SHOULD be ignored per the inline blob.
getHandler("add")("/tmp/artifacts/debug.log", fileStats());
expect(send).not.toHaveBeenCalled();

// The inline blob path must not touch the filesystem at all.
expect(fs.existsSync).not.toHaveBeenCalled();
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("file event for .artifactignore does not overwrite the inline blob", () => {
const { send } = createManager({}, "/tmp/artifacts", {
ignoreContent: "*.log\n",
});

// A .artifactignore file event would, without the pin, replace the
// patterns with whatever the file contains. The pin should short-circuit
// before the runtime even checks the filesystem.
getHandler("change")(
"/tmp/artifacts/.artifactignore",
fileStats(),
);

// The inline blob is still in effect, so *.log is still ignored.
getHandler("add")("/tmp/artifacts/debug.log", fileStats());
expect(send).not.toHaveBeenCalled();
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("warns when neither ignoreContent nor a .artifactignore file is provided", () => {
const { logger } = createManager();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"No artifact ignore patterns provided for /tmp/artifacts",
),
);
});

it("does not warn when an .artifactignore file is found", () => {
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
vi.mocked(fs.readFileSync).mockReturnValueOnce("*.log\n");
const { logger } = createManager();
expect(logger.warn).not.toHaveBeenCalled();
});

it("does not warn when ignoreContent is provided", () => {
const { logger } = createManager({}, "/tmp/artifacts", {
ignoreContent: "*.log\n",
});
expect(logger.warn).not.toHaveBeenCalled();
});
});
});
Loading
Loading