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
27 changes: 23 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ drivers.
`ConversationError::Legacy` and `::Worker` are removed.
- Classic `memory/conversations/` files are no longer read by this crate.

### Artifact offload and generated media: blob store

- `artifacts::ArtifactOffload` writes to a tinystoragedrivers `BlobStore`
(`ArtifactOffload::new(blobs, agent, task)`; `ArtifactOffload::in_memory`
is the default for library hosts) instead of the filesystem. Pointers carry
the blob key (`outputs/<name>`); `ArtifactOffload::read` and `::blobs`
fetch it back. Removed: `ArtifactOffload::root` and `::with_render_root`,
`OffloadedArtifact::absolute_path`, `OffloadError::Io` and `::SymlinkEscape`
(new `OffloadError::Storage`), `relative_to_root`, and
`resolve_artifact_path` (now `resolve_artifact_key`, with no root argument).
- `media::MediaOutput` stores generated images and clips on a blob store
(`MediaOutput::with_blobs`, in-memory by default) under `generated-media/`;
tool results report the blob key as `path`. Local reference inputs still
resolve against the workspace. `rustix` is no longer a dependency.
- Hosts that let the model open these artifacts by file path must expose the
store through their read tool or materialise it themselves.

### Removed, with the replacement

| Removed | Use instead |
Expand Down Expand Up @@ -100,9 +117,11 @@ let report = import_workspace(&provider, "agent-id", workspace_dir).await?;
### Not yet moved

These still use files and are migrated in follow-ups (tracked in
tinyhumansai/openhuman#7688 and #7768):
tinyhumansai/openhuman#7688, #7768 and #7783):

- the JSONL transcript writer, reader and `FileTranscriptLocator`
(path-bound; `session_raw/` adoption, spend and view helpers read them);
- the artifact offload (`artifacts::ArtifactOffload`) and media outputs, which
write files the model later opens by path.
(path-bound; `session_raw/` adoption, spend and view helpers read them,
about 12k lines across `session::transcript`);
- `artifacts::tool_results::ToolResultArtifactStore`, whose pointers are
action-relative or absolute file paths the host's `file_read` opens, and
whose pruning relies on file mtimes (a blob has no modification time).
1 change: 0 additions & 1 deletion Cargo.lock

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

1 change: 0 additions & 1 deletion crates/tinyagents-harness/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ flate2 = { version = "1", optional = true }
oxipng = { version = "9", default-features = false, optional = true }
futures = { workspace = true }
regex = "1"
rustix = { version = "1", features = ["fs"] }
reqwest = { workspace = true, features = ["stream", "http2"], optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
39 changes: 18 additions & 21 deletions crates/tinyagents-harness/src/artifacts/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# harness::artifacts

Filesystem offload for oversized worker results on long-horizon runs, plus the
fail-closed path resolution and host policy hooks it depends on.
Blob-store offload for oversized worker results on long-horizon runs, plus the
fail-closed key resolution and host policy hooks it depends on.

## Why this exists

Expand Down Expand Up @@ -31,10 +31,10 @@ already had.

## Public surface

- [`ArtifactOffload`] (`ops.rs`) — per-run writer holding the resolved
artifact root, host policies, and the ids used to name artifacts and tag
logs. `write` / `write_returning_stored` perform a redacted write;
`resolve` validates a path without writing.
- [`ArtifactOffload`] (`ops.rs`) — per-run writer holding a
`BlobStore`, host policies, and the ids used to name artifacts and tag
logs. `write` / `write_returning_stored` perform a redacted write; `read`
fetches one back by key; `resolve` validates a name without writing.
- [`offload_oversized_result`] (`ops.rs`) — the deterministic entry point:
offloads `output` when it exceeds a threshold, returning the text the
parent should receive plus the artifact when one was written.
Expand All @@ -43,12 +43,11 @@ already had.
[`note_artifact_handoff`] (`ops.rs`) — the supporting free functions:
threshold decision, cap-aware threshold, abstract truncation, pointer
rendering, pointer parsing, and handoff logging.
- [`resolve_artifact_path`], [`relative_to_root`], [`sanitize_component`]
(`paths.rs`) — fail-closed path resolution under the convention root and
the helpers it builds on.
- [`resolve_artifact_key`], [`sanitize_component`] (`paths.rs`) — fail-closed
key resolution under the two convention namespaces and the helper it builds on.
- [`policy`] module (`policy.rs`) — the two host-supplied gates:
[`ArtifactPathPolicy`] (which paths are off limits) and
[`ArtifactRedactor`] (what is scrubbed before bytes hit disk), plus the
[`ArtifactRedactor`] (what is scrubbed before bytes are stored), plus the
permissive [`OpenPathPolicy`] / [`NoRedaction`] defaults used in tests and
by hosts with nothing to restrict.
- [`ArtifactKind`], [`OffloadedArtifact`], [`OffloadError`] and the
Expand All @@ -64,31 +63,29 @@ already had.
| ------------ | --------------------------------------------------------------------- |
| `mod.rs` | Module overview, re-exports. |
| `types.rs` | `ArtifactKind`, `OffloadedArtifact`, `OffloadError`, constants. |
| `paths.rs` | Fail-closed path resolution (`resolve_artifact_path` and helpers). |
| `paths.rs` | Fail-closed key resolution (`resolve_artifact_key`, `sanitize_component`). |
| `policy.rs` | `ArtifactPathPolicy`, `ArtifactRedactor` traits and their defaults. |
| `tool_results.rs` | Per-tool-result persistence: `ToolResultArtifactStore` (`new`: under `<action_dir>/artifacts/tool-results/`, relative pointers; `detached`: under `<storage_dir>/tool-results/` outside the working tree, absolute pointers), `apply_per_result_persistence`, `spill_aggregate_tool_results`, paged artifact reads (`read_target`). Host supplies redactor, read/wrapper tool names, read limit, and for a detached store the read grant on its artifact namespace. |
| `tool_results_tests.rs` | Tests for the above, incl. a byte-stable envelope fixture. |
| `ops.rs` | `ArtifactOffload` writer, `offload_oversized_result`, pointer/handoff plumbing. |
| `ops.rs` | `ArtifactOffload` writer over a `BlobStore`, `offload_oversized_result`, pointer/handoff plumbing. |
| `mod_tests.rs`, `contract_tests.rs`, `policy_tests.rs`, `request_truncation_tests.rs` | Unit tests: happy path, fallback path, fail-closed hardening. |

## Operational constraints

- `resolve_artifact_path` is lexical only — it cannot see symlinks, because
the write target usually does not exist yet. `ArtifactOffload::write`
re-validates the real, symlink-resolved parent directory after
`create_dir_all` and before the write; that is the first moment a
pre-existing symlink escape can be detected.
- Artifacts are blobs on a tinystoragedrivers `BlobStore` (in memory unless
the host passes its scoped handle to `ArtifactOffload::new`). A pointer
carries the blob key (`outputs/<name>`), not a filesystem path: read it back
with `ArtifactOffload::read`, or have the host expose the store through its
own read tool or materialise it. `resolve_artifact_key` is fail-closed on
`..`, absolute names, drive prefixes and control characters; there are no
symlinks to escape through.
- A `None` redactor stores bytes verbatim. That is a legitimate choice for a
host with nothing to scrub, but it is a security decision the host must make
explicitly — this crate cannot supply credential/PII patterns for it.
- Any preview surfaced back into a model's context must be built from the
**stored** (post-redaction) body, never from the caller's original input.
`write_returning_stored` and `offload_oversized_result` exist specifically
so callers have that stored value at hand.
- `ArtifactOffload::with_render_root` matters whenever a worker writes inside
its own checkout: the parent receiving the pointer does not hold the
worker's root, so paths must be rendered relative to the *parent's* root
(or absolute, when the worker's root is not nested inside it).
- Every write and every handoff emits a structured `[artifact]` log line, so a
run journal shows both ends of a pointer (see `mod.rs` for the exact
messages).
2 changes: 1 addition & 1 deletion crates/tinyagents-harness/src/artifacts/contract.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! The prompt-side half of the artifact-offload convention (#3883).
//!
//! Single source of truth for the directory names the model is told about, so
//! the prompt and [`super::resolve_artifact_path`] can never drift. The write
//! the prompt and [`super::resolve_artifact_key`] can never drift. The write
//! tool the contract may name is a parameter: tool names are host vocabulary.

use std::collections::HashSet;
Expand Down
21 changes: 9 additions & 12 deletions crates/tinyagents-harness/src/artifacts/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Filesystem offload for oversized worker artifacts on long-horizon runs.
//! Blob-store offload for oversized worker artifacts on long-horizon runs.
//!
//! ## The problem
//!
Expand Down Expand Up @@ -37,25 +37,22 @@
//!
//! ## Hardening
//!
//! [`resolve_artifact_path`] is fail-closed. Absolute paths, `..` traversal, and
//! anything escaping the convention root are refused; when an
//! [`resolve_artifact_key`] is fail-closed. Absolute names, drive prefixes, `..`
//! traversal and control characters are refused; when an
//! [`ArtifactPathPolicy`] is supplied, so is anything reaching the host's
//! internal state.
//!
//! The lexical checks cannot see symlinks, because the target does not exist
//! when they run. So the real parent directory is re-validated after
//! `create_dir_all` and before the write — that is the first moment the
//! link-resolved location can be checked at all.
//! internal state. The artifacts live on a tinystoragedrivers blob store, not
//! the filesystem, so the key is the whole address and there is no symlink to
//! follow out of the namespace.
//!
//! ## What the host supplies
//!
//! Two policies, both of which a redistributed crate cannot decide for itself:
//! [`ArtifactPathPolicy`] (which paths are off limits) and [`ArtifactRedactor`]
//! (what is scrubbed before bytes hit disk). See [`policy`].
//! (what is scrubbed before bytes are stored). See [`policy`].
//!
//! ## Logging
//!
//! Every write emits `[artifact] wrote worker artifact under the artifact root`,
//! Every write emits `[artifact] stored worker artifact on the blob store`,
//! and every path a handoff carries emits `[artifact] handoff carried an
//! artifact path`, so a run journal shows both ends of a pointer.

Expand All @@ -75,7 +72,7 @@ pub use ops::{
effective_offload_threshold, extract_artifact_paths, note_artifact_handoff,
offload_oversized_result, render_artifact_pointer, should_offload,
};
pub use paths::{relative_to_root, resolve_artifact_path, sanitize_component};
pub use paths::{resolve_artifact_key, sanitize_component};
pub use policy::{ArtifactPathPolicy, ArtifactRedactor, NoRedaction, OpenPathPolicy, Redacted};
pub use request_truncation::{
RequestTruncation, reducible_tool_result_bytes, truncate_older_tool_results,
Expand Down
Loading
Loading