Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
113 changes: 111 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,99 @@ 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();
});

it("treats ignoreContent: null the same as omitted", () => {
// The session forwards `message.artifacts_ignore_content` straight into
// options, and Python clients serialize unset optional fields as JSON
// null. The manager must not pass that null into the ignore parser.
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
vi.mocked(fs.readFileSync).mockReturnValueOnce("*.log\n");

const { send } = createManager({}, "/tmp/artifacts", {
ignoreContent: null as unknown as string | undefined,
});
// Falls back to the on-disk .artifactignore loaded above.
getHandler("add")("/tmp/artifacts/debug.log", fileStats());
expect(send).not.toHaveBeenCalled();
});
});
});
Loading