diff --git a/Cargo.lock b/Cargo.lock index cba28e0..b7b92e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1297,7 +1297,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "2.1.0" +version = "2.1.1" dependencies = [ "async-trait", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 17cdf1e..f4ef320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinyagents" -version = "2.1.0" +version = "2.1.1" edition = "2024" license = "GPL-3.0-only" description = "A recursive language-model (RLM) harness for Rust." diff --git a/docs/modules/harness/README.md b/docs/modules/harness/README.md index 942e949..9a40cd9 100644 --- a/docs/modules/harness/README.md +++ b/docs/modules/harness/README.md @@ -240,6 +240,7 @@ Feature details: - [Prompt feature](prompt.md) - [Tool feature](tool.md) - [Workspace isolation feature](workspace.md) +- [Host capability seams](host.md) - [Middleware feature](middleware.md) - [Sub-agent and orchestrator steering](subagent-steering.md) - [Structured output feature](structured-output.md) diff --git a/docs/modules/harness/host.md b/docs/modules/harness/host.md new file mode 100644 index 0000000..a1221bb --- /dev/null +++ b/docs/modules/harness/host.md @@ -0,0 +1,169 @@ +# Host capability seams (`harness::host`) + +Ten extension traits an embedding application implements, plus one inert +default per trait so a host can adopt them one at a time. Source lives in +`src/harness/host/`, one file per capability, re-exported through +`src/harness/host/mod.rs` and again at the crate root. + +The accepted design record — motivation, per-trait signatures and rationale, the +ten-trait budget, the rejected configuration alternatives, and the open questions +— is [`docs/spec/host-capabilities-spec.md`](../../spec/host-capabilities-spec.md). +This file covers the operational detail an implementer needs day to day. + +## Why a separate module + +Every other harness module names a concern the crate itself implements — +`memory` ships `InMemoryChatHistory`, `model` ships real providers, `store` +ships a file-backed store. These ten are the opposite axis: points where the +crate deliberately has **no** opinion and ships only defaults that do nothing. +That is a different kind of thing and it gets its own name. + +Keeping them separate also does one concrete job: it keeps `MemoryProvider` out +of `harness::memory`. Putting a scored-retrieval trait next to `ChatHistory` +invites exactly the confusion the scope table below exists to prevent, and a +doc comment is a weaker fence than a different module. + +## The ten + +| Trait | Question it answers | Default | +|-------|---------------------|---------| +| `MemoryProvider` | What does the host know that is relevant to this text? | `InMemoryMemoryProvider` | +| `ContextComposer` | What text precedes the model request this turn? | `PassthroughContextComposer` | +| `SecurityGate` | Is this tool, path, input, or call permitted? | `RootContainedSecurityGate` | +| `BudgetGate` | May this work start, what did it cost, may it continue? | `UnmeteredBudgetGate` | +| `DefinitionRegistry` | Which agents exist and which is the default? | `InMemoryDefinitionRegistry` | +| `ExperienceStore` | What did prior runs of this shape learn? | `NoopExperienceStore` | +| `LearningSink` | Here is completed work — derive what you like from it. | `NoopLearningSink` | +| `ProgressSink` | Deliver run events to an out-of-process consumer. | `NoopProgressSink` | +| `ToolOutcomeClassifier` | What kind of failure was that? | `NoopToolOutcomeClassifier` | +| `ModelResolver` | Which models should this unit of work use? | `StaticModelResolver` | + +Ten is a budget, not a starting point. An eleventh seam is evidence that one of +these is drawn wrong; reopen the design rather than appending. + +## Scope boundaries + +These are the confusions that cost real debugging time when they are left +implicit. + +| This | Is not | Because | +|------|--------|---------| +| `MemoryProvider` | `ChatHistory` | Scored retrieval over a namespaced corpus, not a thread's ordered message list. No `messages()`, no `append`, no offsets, no replay. Nothing here reads, rewrites, or compacts a transcript. | +| `MemoryProvider` | `Store` / `AppendStore` | Those are opaque key/value and offset-addressed streams with no notion of relevance. | +| `SecurityGate` | `WorkspaceIsolation` | `WorkspaceIsolation` prepares an environment; `SecurityGate` decides what is permitted inside one. | +| `SecurityGate::filter_tools` | `SecurityGate::authorize_call` | The first decides what the model is *told* exists. The second decides whether one concrete call — arguments and all — may run. A gate that only narrows the advertised set admits anything the model names from memory. | +| `ProgressSink` | `EventListener` | `EventListener` is synchronous and must not block the emitting step. `ProgressSink` may await, for consumers behind a channel, socket, or IPC boundary. | +| `ProgressSink` | a transport | Delivery only. There is no receive side; an interactive loop that reads from a terminal or a chat platform is host surface. | +| `BudgetGate` | context compaction | Money and admission. Fitting a conversation into a window is `Summarizer` plus `RunLimits`. | +| `DefinitionRegistry` | workspace layout or prompt personality | It supplies `AgentDefinition`. Directory roots are `WorkspaceIsolation`, personality is `ContextComposer`, scoped retrieval is `MemoryQuery::namespace` / `ExperienceQuery::partition`. | + +## Async only where the work is async + +A seam is `async` when a realistic implementation performs I/O, and a plain +`fn` otherwise. The precedent is `ChatModel`, which already pairs a sync +`profile` with an async `invoke`. + +| Sync | Async | +|------|-------| +| `ContextComposer::compose_system_prompt` | `ContextComposer::prepare_turn` | +| all of `SecurityGate` except `authorize_call` | `SecurityGate::authorize_call` | +| `BudgetGate::estimate_cost` | rest of `BudgetGate` | +| all of `ModelResolver` | — | +| all of `ToolOutcomeClassifier` | — | + +This is not stylistic. Marking a seam `async` forces every caller above it to +become `async` too, and those callers are frequently synchronous session +assembly, artifact persistence, and cold-boot resume paths. That cascade is +paid by the embedder, so the default is sync and `async` has to earn its place. + +## Configuration is not a seam + +There is no `ConfigProvider`. Crate-side configuration is expressed as +crate-owned structs the host populates when it builds a run — explicit, +versionable, and free of virtual calls on every read. + +That covers build-time configuration. It does not, on its own, cover a value +that must be re-read *mid-session* so a live toggle takes effect without +rebuilding. The vehicle for those is `State`: every method on every trait here +receives `&State`, so a host that parks a reloadable handle in its state type +keeps read-at-call-time semantics with no crate surface at all. A host with +such toggles should say so in its own adapter and test it; the crate neither +helps nor hinders. + +## Product vocabulary stays out + +The crate is published. A field name, an enum variant, or a doc-comment example +that encodes one embedder's internal concept becomes public API for everyone +and ships to docs.rs. + +The rules the ten traits follow: + +- Every routing or grouping decision that would carry product meaning is an + opaque host-defined string the crate never interprets: + `ModelResolution::workload`, `ToolExposureRequest::entrypoint`, + `ExperienceQuery::partition`, `MemoryFilter::category`. +- Every user-facing string is host-authored and passed through verbatim: + `ToolExposure::boundary_note`, `InputVerdict::Refuse::message`, + `BudgetVerdict::Stop::reason`, all four strings on `ToolFailure`. +- `ToolFailure::class` and `::category` are opaque `String`s for display and + logging **only**. Crate code must never branch on them — that is what + `RetryDisposition` (`Unknown` / `Never` / `Immediate` / `Backoff`) is for. A + plain `retryable: bool` was rejected because it collapses "we do not know" + into "yes", which silently widens whatever retry ladder consumes it. +- `DefinitionRegistry::default_id` exists so no default agent id is hard-coded + in the crate. +- `BudgetLease` is `Box` so the crate can hold a host's + semaphore permit for the right duration without knowing its type. +- `SystemPromptRequest` carries the crate's own `WorkspaceDescriptor` rather + than a second, differently-named pair of roots. + +`tests/host_seam_hygiene.rs` enforces this mechanically: it scans +`src/harness/host/` for embedder vocabulary and fails with file, line, and +reason. **As runtime code is relocated into this crate, `SCANNED_DIRS` in that +test must grow with it** — a relocation that does not widen the list has not +been checked. + +## `AgentDefinition` is deliberately small + +`AgentDefinition` carries an id, an optional description, an optional rendered +system prompt, an optional model, a tool-name list, and an opaque +`extras: Value`. Nothing else. + +Hosts routinely have far richer definition types — prompt-source indirection, +compaction profiles, delegation overrides, workspace layout. Those map *into* +this type at registration time and ride in `extras`. Two reasons: + +1. A published type that grows a field per host concept is a breaking change + every time the host learns something new. Adding a key to `extras` breaks + no downstream build. +2. Host field names and their doc comments are product vocabulary, and this + type is the most exposed surface in the module. + +`Default` is derived so hosts can construct with struct-update syntax and keep +compiling if the crate ever does add a field. + +## `ContextPlacement` encodes an invariant, not a preference + +`TurnPrefix` (default) splices a fragment onto the turn's user message; +`SystemPrefix` prepends it to the system prompt. The distinction is +load-bearing in two ways: run-stable content in the system prompt is what keeps +a provider's cached prefix valid across turns, and history trimming commonly +hoists system messages to the front, which reorders content that was meant to +ride one specific turn. Making placement a typed property is what stops that +being rediscovered. + +## Default-implementation behaviour worth knowing + +- `InMemoryMemoryProvider` scores by whitespace-token substring containment + (`matched / total`), sorts by score descending with `key` ascending as the + tiebreak so results are deterministic despite the backing `HashMap`, and + short-circuits an empty query to `Ok(vec![])` before any division. + `namespace_digests` takes the empty trait default. +- `RootContainedSecurityGate::resolve_path` normalises **lexically** and never + touches the filesystem, so a path that does not exist yet resolves exactly + like one that does. It is therefore not a defence against symlinks; a host + that cares must layer that on. +- `NoopProgressSink::is_connected` returns `false`, deliberately overriding the + trait default, so callers take their skip-expensive-payload path. +- `UnmeteredBudgetGate` implements only `record_usage`; every other method is + the trait default, which is the "no budget exists" answer throughout. diff --git a/docs/spec/README.md b/docs/spec/README.md index cfe1767..41ab094 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -64,6 +64,7 @@ contracts. - [Streaming](../modules/harness/streaming.md) - [Store](../modules/harness/store.md) - [Observability and events](../modules/harness/observability.md) + - [Host capability seams](../modules/harness/host.md) - [Testkit](../modules/harness/testkit.md) - [Graph module](../modules/graph/README.md) - [Package and core types](../modules/graph/package.md) @@ -127,6 +128,13 @@ output, observability, and testability), and [`docs/modules/harness/README.md`](../modules/harness/README.md) for the per-topic implementation docs. +The capabilities the harness expects an embedding application to supply — long- +term memory retrieval, context composition, security policy, budgets, agent +definitions, prior-run experience, learning, progress delivery, tool-failure +classification, and model resolution — are specified as ten traits in +[`host-capabilities-spec.md`](host-capabilities-spec.md). Each ships an inert +default so a host can adopt them one at a time. + Per-tool deadlines are opt-in at the harness boundary through `AgentHarness::with_tool_timeout_settings`. A tool's `ToolTimeout` policy is resolved from the final post-middleware call: `Inherit` uses the shared dynamic diff --git a/docs/spec/host-capabilities-spec.md b/docs/spec/host-capabilities-spec.md new file mode 100644 index 0000000..d285924 --- /dev/null +++ b/docs/spec/host-capabilities-spec.md @@ -0,0 +1,500 @@ +# Host Capability Seams Specification + +**Status:** accepted; implemented in `src/harness/host/` (`harness::host`). +**Extends** [`harness-spec.md`](harness-spec.md); implementation notes in +[`docs/modules/harness/host.md`](../modules/harness/host.md). + +This document is the accepted catalogue of the ten host capability traits the +harness exposes, the reasoning behind each seam, and the boundaries that keep them +from growing. An implementer reading only this file should be able to tell what a +trait is for, what it is deliberately not for, and what would have to be true +before an eleventh is added. + +## Motivation + +The harness already separates *how a model is called* from *who supplies the +model*. `ChatModel`, `Tool`, `ChatHistory`, and `Store` all exist because a +runtime that hard-codes those decisions can only serve the application it was +extracted from. + +The same argument applies one level up, to a set of decisions the harness +currently has no vocabulary for. A production agent runtime must at minimum +answer: what long-term knowledge is relevant to this input, beyond the thread's +own message list; what text precedes the model request and where it is spliced so +a cached prompt prefix survives; whether this tool, path, input, or concrete call +is permitted; whether this work may start, what it cost, and whether it may +continue; which agent definitions exist and which is the default; what earlier +runs of this shape learned; who receives completed work so durable knowledge can +be derived from it; who receives live events across a process boundary; what kind +of failure just occurred and whether it should be retried; and which models this +unit of work uses. + +Every embedder answers all ten. Today each answers them by wrapping the harness +in bespoke glue, which means the seams are defined implicitly, differently, and +by whoever integrated first. Naming them as traits does three things: it makes +the runtime's requirements legible without reading an integration, it lets the +harness own ordering and assembly (fragment placement, lease lifetime, event +offsets) rather than leaving them to each host, and it gives the crate a place +to ship an inert default so a partial adoption still runs. + +None of the ten introduce a new architectural idea; they are more of the pattern +the crate already uses eighteen times. OpenHuman is the first consumer and drove +the call-site grounding below, but the catalogue is deliberately free of its +vocabulary, and the rules in [Publishability](#publishability-boundary) are +enforced by a test rather than by convention. + +## Scope + +**In scope:** trait definitions, their request/response types, one inert default +implementation each, and the module they live in. + +**Out of scope, deliberately:** wiring (the traits land unreferenced by the +runtime; a bundle struct on `AgentHarness`/`Session` is a separate change); the +durable transcript (compaction-replacement records, interrupted partials, and +display-order reads belong to `ChatHistory` and are specified separately — +nothing here reads, writes, or compacts a transcript); and configuration, for +which see [Configuration](#configuration-is-not-a-seam). + +## Relationship to the existing extension traits + +The crate ships eighteen extension traits today: `ChatModel`, `Tool`, +`Middleware`, `ModelMiddleware`, `ToolMiddleware`, `ModelBaseCall`, +`ToolBaseCall`, `ChatHistory`, `Store`, `AppendStore`, `Summarizer`, +`EmbeddingModel`, `VectorStore`, `ResponseCache`, `WorkspaceIsolation`, +`HarnessEventJournal`, `HarnessStatusStore`, and `EventListener`. + +The ten here follow the same conventions — `Send + Sync` supertrait, +`crate::error::Result`, `&self` receivers, `&State` as the first parameter, +borrowed `&Request<'_>` structs for reads and owned values for writes, default +method bodies for everything optional. They are consumed as +`Arc>` exactly like `ChatModel` and `Tool`. + +They live in a **new module**, `harness::host`, rather than being scattered into +the concern modules they sit beside. Every existing harness module names a +concern the crate itself implements: `memory` ships `InMemoryChatHistory`, `model` +ships real providers, `store` ships a file-backed store. These ten are the +opposite axis — points where the crate deliberately has no opinion and ships only +defaults that do nothing. Separation also does one concrete job: it keeps +`MemoryProvider` out of `harness::memory`, where a scored-retrieval trait sitting +next to `ChatHistory` would invite exactly the confusion below, and a doc comment +is a weaker fence than a different module. + +Four non-overlaps are worth stating outright, because each has already been +mistaken once. `MemoryProvider` is not `ChatHistory`: scored retrieval over a +namespaced corpus, not a thread's ordered message list — no `messages()`, no +`append`, no offsets, no replay. `SecurityGate` is not `WorkspaceIsolation`: +the latter prepares an environment, the former decides what is permitted inside +one. `ProgressSink` is not `EventListener`: `EventListener` is synchronous and +must not block the emitting step. `BudgetGate` is not `Summarizer`: money and +admission, not fitting a conversation into a window. The full table, including +`Store` and the intra-trait boundaries, is in the module doc. + +## The catalogue + +Signatures below are abridged to the trait surface; types, builders, and full doc +comments are in `src/harness/host/`. + +### 1. `MemoryProvider` — retrieval-oriented long-term memory + +```rust +#[async_trait] +pub trait MemoryProvider: Send + Sync { + async fn recall(&self, state: &State, query: &MemoryQuery<'_>) -> Result>; + async fn list(&self, state: &State, filter: &MemoryFilter<'_>) -> Result>; + async fn write(&self, state: &State, record: MemoryWrite) -> Result<()>; + async fn namespace_digests(&self, state: &State, caps: DigestCaps) + -> Result> { Ok(Vec::new()) } +} +``` + +`MemoryQuery` carries `text`, `limit: Option` (`None` means the backend's +own default, `DEFAULT_RECALL_LIMIT` in-crate), `namespace`, `thread_id`, +`cross_thread`, and `min_score`. `MemoryFilter` is the unscored counterpart: +`namespace`, `category`, `thread_id`, `limit`. `MemoryRecord` carries identity, +key, content, optional namespace/category/thread/score/timestamp, and an opaque +`attributes: Value` so backends round-trip provenance the crate does not model. +`limit` is `Option` rather than `usize` because a derived `Default` with a bare +`usize` yields `limit: 0`, and `MemoryQuery { text, ..Default::default() }` is +exactly how a first-time caller writes it. + +**Default:** `InMemoryMemoryProvider`, a mutex-guarded map scoring by +whitespace-token containment — same shape and same durability guarantees (none) +as `InMemoryChatHistory` and `InMemoryStore`. + +### 2. `ContextComposer` — system prompt and per-turn fragments + +```rust +#[async_trait] +pub trait ContextComposer: Send + Sync { + fn compose_system_prompt(&self, state: &State, request: &SystemPromptRequest<'_>) + -> Result; + async fn prepare_turn(&self, state: &State, request: &TurnPreparationRequest<'_>) + -> Result { Ok(TurnPreparation::default()) } +} +``` + +`prepare_turn` returns `TurnPreparation { blocks: Vec, extras: +Value }`, each block being `{ id, body, placement, priority }`. Per-turn +enrichment becomes an ordered set of registered fragments rather than a bespoke +method body: the crate owns ordering, placement, and assembly, the embedder owns +every byte of text. + +`ContextPlacement` (`TurnPrefix` default, `SystemPrefix`) encodes an invariant, +not a preference. Run-stable content in the system prompt keeps a provider's +cached prefix valid across turns, and history trimming commonly hoists system +messages to the front, reordering content meant to ride one specific turn. A +typed placement is what stops that being rediscovered. + +`SystemPromptRequest` carries the run/thread ids, `agent_id`, `model_id`, the +tool schemas, `visible_tool_names`, `tool_call_instructions`, and +`workspace: Option<&WorkspaceDescriptor>` — the crate's existing descriptor, +which already models a primary root plus trusted roots. `TurnPreparationRequest` +adds `input`, `turn_index`, `first_turn`, and `resumed`. + +**Default:** `PassthroughContextComposer` — empty prompt, empty preparation, +no observable effect on a run. + +### 3. `SecurityGate` — what the run may see and touch + +```rust +#[async_trait] +pub trait SecurityGate: Send + Sync { + fn filter_tools(&self, state: &State, request: &ToolExposureRequest<'_>) + -> Result { /* everything visible */ } + async fn authorize_call(&self, state: &State, request: &ToolCallRequest<'_>) + -> Result { Ok(CallVerdict::Allow) } + fn resolve_path(&self, state: &State, request: &PathRequest<'_>) -> Result; + fn screen_input(&self, state: &State, request: &InputScreenRequest<'_>) + -> Result { Ok(InputVerdict::Admit) } + fn redact(&self, state: &State, request: &RedactionRequest<'_>) + -> Result { Ok(Redaction::unchanged(request.text)) } +} +``` + +The crate defines no policy here: it defines the questions. Two distinctions +carry the weight. First, `filter_tools` is *advertisement* and +`authorize_call` is *enforcement*. A gate that only narrows the advertised set +admits anything the model names from memory, and the advertised set is name-only +while a real decision depends on the arguments. Hence `ToolCallRequest` carries +`arguments: &Value`, and `CallVerdict` is three-valued — +`Allow` / `Deny { code, message }` / `RequireApproval { code, message }` — +because a policy that can only allow or deny cannot express "ask a human first" +and silently degrades into one of the other two. + +Second, `screen_input` returns a verdict and `redact` returns modified text, because +masking a secret — or fencing untrusted content so a model treats it as data +rather than instruction — is not expressible as `Admit`/`Refuse`. `redact` runs +in both directions (`RedactionDirection::{Inbound, Outbound}`): inbound text on +its way into a prompt, outbound tool output on its way into storage or a preview. + +**Default:** `RootContainedSecurityGate`. `resolve_path` normalises **lexically** +and never touches the filesystem, so a path that does not exist yet resolves +exactly like one that does; it is therefore not a defence against symlinks. The +other four methods take their permissive trait defaults, which is stated plainly +rather than described as "fail-closed": a host that forgets to wire a gate runs +unpoliced on four of the five questions. + +### 4. `BudgetGate` — admission control and cost accounting + +```rust +#[async_trait] +pub trait BudgetGate: Send + Sync { + async fn acquire(&self, state: &State, request: &AdmissionRequest<'_>) + -> Result> { Ok(Some(BudgetLease::unmetered())) } + fn estimate_cost(&self, state: &State, model_id: &str, usage: &Usage) -> CostTotals + { CostTotals::default() } + async fn record_usage(&self, state: &State, entry: &UsageEntry<'_>) -> Result<()>; + async fn account_turn(&self, state: &State, charge: &TurnCharge<'_>) + -> Result { Ok(BudgetVerdict::Continue) } +} +``` + +The crate already tracks `Usage` and `CostTotals` and enforces `RunLimits`. This +seam is for *external* budgets: process-wide concurrency, pricing tables, and +durable spend ledgers the crate cannot know about. `Ok(None)` from `acquire` +means admission was refused without an error — a paused scheduler, not a failure. +`BudgetVerdict::Stop { reason }` is a graceful request drained at the next +iteration boundary, not an abort; a bounded overshoot is expected. `BudgetLease` +is `Box` with an `into_inner`, so the crate holds a host's +semaphore permit for the right duration without knowing its type. + +**Default:** `UnmeteredBudgetGate` implements only `record_usage`, as `Ok(())`. +Every other method is the trait default, which is the "no budget exists" answer +throughout, so wiring it is observationally identical to an ungated run. + +### 5. `DefinitionRegistry` — which agents exist + +```rust +#[async_trait] +pub trait DefinitionRegistry: Send + Sync { + async fn get(&self, state: &State, id: &str) -> Result>; + async fn list(&self, state: &State) -> Result>; + async fn default_id(&self, state: &State) -> Result> { Ok(None) } +} +``` + +Read-only by design: mutation, validation, and precedence between sources are the +embedder's concern and happen before `list` returns. An unknown id is `Ok(None)`, +not an error. `default_id` keeps a default agent identity out of the crate. + +`AgentDefinition` is deliberately small — `id`, optional `description`, optional +rendered `system_prompt`, optional `model`, a `tools: Vec` name list, and +an opaque `extras: Value`. Hosts routinely have far richer definition types: +prompt-source indirection, compaction profiles, delegation overrides, workspace +layout. Those map *into* this type at registration time and ride in `extras`. A +published type that grows a field per host concept is a breaking change every +time a host learns something new, and host field names are themselves product +vocabulary. `Default` is derived so hosts construct with struct-update syntax and +keep compiling if the crate ever does add a field. + +**Default:** `InMemoryDefinitionRegistry`, insertion-ordered, constructed empty. + +### 6. `ExperienceStore` — what prior runs learned + +```rust +#[async_trait] +pub trait ExperienceStore: Send + Sync { + async fn retrieve(&self, state: &State, query: &ExperienceQuery<'_>) + -> Result>; + async fn record(&self, state: &State, entries: Vec) + -> Result<()> { Ok(()) } +} +``` + +Distinct from `MemoryProvider`, which serves the user's corpus: this serves the +agent's own record of what worked. The crate stores nothing and renders nothing — +`ExperienceHit::body` is host-rendered and used verbatim, and a hit with empty +`match_reasons` carries no evidence for why it was selected, so callers may drop +it. `ExperienceQuery::partition` is an opaque key; `None` searches all of them. + +**Default:** `NoopExperienceStore` — empty retrieval, discarding `record`. + +### 7. `LearningSink` — completed work, for derivation + +```rust +#[async_trait] +pub trait LearningSink: Send + Sync { + async fn on_turn_completed(&self, state: &State, record: &TurnRecord) -> Result<()>; + async fn on_transcript_committed(&self, state: &State, commit: &TranscriptCommit<'_>) + -> Result<()> { Ok(()) } +} +``` + +Fire-and-forget by contract: the runtime does not wait on the result and treats +an `Err` as a logged failure, never a run failure. Implementations must return +promptly and defer real work to a background task. `TurnRecord` carries the run, +optional thread/agent/entrypoint, input, output, tool outcomes, model-call count, +and elapsed time; each `ToolOutcomeRecord::summary` is a bounded, non-sensitive +description, never raw output. + +**Default:** `NoopLearningSink`, indistinguishable from registering nothing. + +### 8. `ProgressSink` — asynchronous delivery of run events + +```rust +#[async_trait] +pub trait ProgressSink: Send + Sync { + fn is_connected(&self, state: &State) -> bool { true } + async fn deliver(&self, state: &State, record: &EventRecord) -> Result<()>; +} +``` + +Complements `EventListener`, which is synchronous and must not block the emitting +step. A `ProgressSink` may await — it exists for consumers behind a bounded +channel, a socket, or an IPC boundary, where silently dropping events is not +acceptable. `is_connected` lets callers skip assembling expensive payloads when +nothing will read them; a sink returning `false` must still tolerate `deliver`. +An `Err` means the consumer is gone; the runtime logs it and continues. + +It carries the crate's own `EventRecord`. No parallel event enum is introduced, +and none should be: projecting crate events into a presentation model is the +embedder's job. It is also **delivery only** — there is no receive side. An +interactive loop reading input from a terminal or a chat platform is host +surface. + +**Default:** `NoopProgressSink`, whose `is_connected` returns `false`, +deliberately overriding the trait default so callers take their +skip-expensive-payload path. + +### 9. `ToolOutcomeClassifier` — what kind of failure was that + +```rust +pub trait ToolOutcomeClassifier: Send + Sync { + fn classify(&self, state: &State, failure: &ToolFailureContext<'_>) -> Option; +} +``` + +Synchronous and pure by contract, mirroring `EventListener`. +`ToolFailureContext::error` arrives with any separate error field and result body +already combined, so a classifier need not guess where the signal is, and +`timed_out` is supplied because text alone does not reliably say whether the +runtime aborted the call on its own deadline. + +`ToolFailure` carries `class`, `category`, `cause`, `next_action` — all +host-authored, all display-and-logging only — plus +`retry: RetryDisposition { Unknown (default), Never, Immediate, Backoff }`. Crate +code branches on `RetryDisposition` and must never branch on `class` or +`category`; that separation is what lets the failure taxonomy stay host-owned +while a retry ladder lives in the crate. A plain `retryable: bool` was rejected: +it collapses "we do not know" into "yes" and silently widens the ladder. + +**Default:** `NoopToolOutcomeClassifier` — `None` for every input. + +### 10. `ModelResolver` — which models this work uses + +```rust +pub trait ModelResolver: Send + Sync { + fn resolve(&self, state: &State, request: &ModelResolution<'_>) + -> Result>; + fn profile(&self, state: &State, model_id: &str) -> Result> { Ok(None) } + fn context_window(&self, state: &State, model_id: &str) -> Result> { Ok(None) } +} +``` + +`resolve` returns a populated `ModelRegistry` — the primary model as the registry +default, plus any additional named routes the run may select — so the crate's own +selection, fallback, and capability checks run unchanged on top of the host's +routing decision. It is a per-**run** call, not per-turn; it allocates a registry +each time. `profile` answers "does this model do native tool calls / accept +images?" before a registry exists, and lets an embedder override a +provider-reported capability from its own configuration; `context_window` +returning `None` means no window-driven compaction is scheduled. +`ModelResolution::workload` is an opaque host-defined label, so routing rules +stay entirely embedder-side. + +**Default:** `StaticModelResolver` — one `Arc>` plus +a name; `resolve` builds a fresh registry and registers it. The in-crate analogue +of `AgentHarness::register_model`. + +## Why ten + +Ten is a budget, not a starting point. Every trait here is grounded in call sites +that exist in a real integration; nothing was added because it seemed +architecturally tidy. An eleventh seam is evidence that one of these ten is drawn +wrong, and the escalation path is to **reopen this document** and re-argue the +partition rather than append to it. Concretely: if a capability does not fit, +first check whether it is a `ContextComposer` registration plus a `LearningSink` +call (that pair covers most "we need a hook here" requests), whether it is an +existing trait (`WorkspaceIsolation`, `Summarizer`, `EmbeddingModel`, `Tool`), or +whether it is host surface that should never have reached the crate. + +`ExperienceStore` is the weakest of the ten by that test and the first candidate +to fold if the budget ever binds: `retrieve` feeds one context block and `record` +is a post-turn hook, so both halves fit the composer/sink pair. + +## Async only where the work is async + +A seam is `async` when a realistic implementation performs I/O, and a plain `fn` +otherwise — precedent: `ChatModel` already pairs a sync `profile` with an async +`invoke`. Sync here: `compose_system_prompt`, all of `SecurityGate` except +`authorize_call`, `estimate_cost`, all of `ModelResolver`, all of +`ToolOutcomeClassifier`. Async: `prepare_turn`, `authorize_call`, the rest of +`BudgetGate`, and all of `MemoryProvider`, `DefinitionRegistry`, +`ExperienceStore`, `LearningSink`, and `ProgressSink::deliver`. + +This is not stylistic. Marking a seam `async` forces every caller above it to +become `async` too, and those callers are frequently synchronous session +assembly, artifact persistence, and cold-boot resume paths — some of which exist +specifically to avoid fanning out to a store. That cascade is paid by the +embedder, so sync is the default and `async` has to earn its place. + +## Configuration is not a seam + +The harness needs configuration, and the accepted answer is **crate-owned config +structs populated by the host when it builds a run**. Explicit, versionable, no +virtual call per read, and it keeps host schema vocabulary out of the crate. No +`ConfigProvider` trait exists and none should be added. + +Two alternatives were considered and rejected: + +- **A config trait with per-value getters.** Rejected. It avoids a mapping layer + but turns every configuration read into a virtual call and becomes a dumping + ground: nothing in its shape resists a fortieth getter, and each one published + is a host concept in the crate's public API. +- **Carrying configuration solely in the generic `State`.** Rejected as the + primary mechanism. It is the least code and the worst discoverability — a + crate-side read needs a bound, and no signature says what the runtime requires. + +That second rejection is narrower than it looks. Crate-owned structs cover +*build-time* configuration; they do not cover a value that must be re-read +*mid-session* so a live toggle takes effect without rebuilding. The vehicle for +those is `State`: every method on every trait here receives `&State`, so a host +that parks a reloadable handle in its state type keeps read-at-call-time +semantics with no crate surface at all. A host with such toggles should say so in +its own adapter and test it; the crate neither helps nor hinders. + +## Publishability boundary + +The crate is published, so a field name, an enum variant, or a doc-comment +example encoding one embedder's internal concept becomes public API for everyone +and ships to docs.rs. The rules the ten traits follow: + +- Every routing or grouping decision that would carry product meaning is an + opaque host-defined string the crate never interprets: + `ModelResolution::workload`, `ToolExposureRequest::entrypoint`, + `ExperienceQuery::partition`, `MemoryFilter::category`. +- Every user-facing string is host-authored and passed through verbatim: + `ToolExposure::boundary_note`, `InputVerdict::Refuse::message`, + `CallVerdict::{Deny,RequireApproval}::message`, `BudgetVerdict::Stop::reason`, + all four strings on `ToolFailure`. +- Where the crate must act on a classification, it defines its own small neutral + vocabulary rather than reading the host's — `RetryDisposition`, not + `ToolFailure::class`. +- `AgentDefinition` stays minimal with an `extras: Value` escape hatch rather + than absorbing host definition fields. +- `SystemPromptRequest` carries the crate's own `WorkspaceDescriptor` rather than + a second, differently-named pair of roots. A product-specific second root was + proposed and rejected: it carried no crate-side meaning and duplicated an + existing seam. +- Presentation payloads do not enter the crate. A typed citation list on + `TurnPreparation` was proposed and rejected: it had no crate consumer, and its + field-by-field mismatch with the host's own presentation type would have quietly + changed what a UI received. `extras: Value` replaces it. + +`tests/host_seam_hygiene.rs` enforces this mechanically: it scans +`src/harness/host/` for embedder vocabulary and fails with file, line, and +reason. It runs under `cargo test`, so it is a gate rather than a convention. +**As runtime code is relocated into this crate, `SCANNED_DIRS` in that test must +grow with it** — a relocation that does not widen the list has not been checked. + +## Adoption phasing + +The traits are designed to land ahead of any consumer, and did. + +1. **Land empty (done).** Traits plus inert defaults, referenced by nothing in + the runtime. No behaviour change; a host that upgrades notices only a larger + public surface. Shipped as a patch release. +2. **Hosts implement in place.** An embedder implements the traits against its + existing internals and repoints its own call sites without relocating code. + This is where the architectural value lands — a host's outbound coupling + collapses to this catalogue — and it is a legitimate stopping point. Wiring + the traits into `AgentHarness`/`Session`, most likely as a + `HostCapabilities` bundle struct, belongs to this step. +3. **Runtime relocation.** Generic runtime code moves into the crate and consumes + the traits directly. Each relocated family must widen + `tests/host_seam_hygiene.rs` and re-check the boundary rules above. + +## Open questions + +- **Should the capability seams be generic over `State`?** They are, matching + `ChatModel` and `Tool`. But only 7 of the 18 existing extension traits carry a + `State` parameter and all 7 are *execution* traits; the other 11 — + `ChatHistory`, `Store`, `Summarizer`, `WorkspaceIsolation`, `EventListener`, + and the rest — are bare `pub trait X: Send + Sync` capturing dependencies in + `Arc` fields, and are what these ten most resemble. `State` is also + load-bearing here for mid-session config reads, which argues for keeping it. + The cost is that every host construction site spells + `Arc>`, and adopting a non-unit `State` later is a + breaking change across every impl. Revisit before step 2 hardens. +- **Sub-agent attribution in `AgentEvent`.** Crate events cannot attribute a + sub-agent's tool and model calls to a specific child task, so a projecting host + must maintain its own stack to synthesize one. No `ProgressSink` signature + fixes this; it is a field gap in `harness::events` and belongs in its own + issue. +- **`BudgetLease` as `Box`.** The only way to hold a + host's permit without depending on its type, but `dyn Any` in a published + struct is unusual. A marker trait with `Box` is more idiomatic + and forces hosts to newtype their permit. Open. +- **`LearningSink::on_transcript_committed` names a `&Path`,** which presumes a + file-backed transcript. If durable history becomes crate-owned and capable of a + database backing, this should become an opaque locator instead. diff --git a/src/harness/embeddings/mod.rs b/src/harness/embeddings/mod.rs index d52367c..45750c2 100644 --- a/src/harness/embeddings/mod.rs +++ b/src/harness/embeddings/mod.rs @@ -21,6 +21,9 @@ //! //! [`OpenAiEmbeddingModel`] adds a hosted provider backed by the OpenAI //! embeddings endpoint (always compiled). +//! [`OllamaEmbeddingModel::embed_discovering_dimensions`] handles +//! provider-managed local models whose vector width is unknown without exposing +//! an adapter whose dimensional identity can change after construction. //! //! The design mirrors LangChain's separation of concerns: chat models generate //! messages, embedding models generate vectors, vector stores search vectors, diff --git a/src/harness/embeddings/ollama.rs b/src/harness/embeddings/ollama.rs index 32151b1..1d291f8 100644 --- a/src/harness/embeddings/ollama.rs +++ b/src/harness/embeddings/ollama.rs @@ -2,6 +2,10 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use super::EmbeddingModel; use crate::error::{Result, TinyAgentsError}; @@ -17,7 +21,7 @@ pub struct OllamaEmbeddingModel { client: reqwest::Client, base_url: String, model: String, - dimensions: usize, + dimensions: Arc, options: Option, } @@ -27,15 +31,51 @@ impl OllamaEmbeddingModel { client: reqwest::Client::new(), base_url: normalize_base_url(base_url)?, model: normalize_model(model)?, - dimensions: if dimensions == 0 { + dimensions: Arc::new(AtomicUsize::new(if dimensions == 0 { DEFAULT_OLLAMA_DIMENSIONS } else { dimensions - }, + })), options: None, }) } + pub(super) fn try_new_unresolved(base_url: &str, model: &str) -> Result { + Ok(Self { + client: reqwest::Client::new(), + base_url: normalize_base_url(base_url)?, + model: normalize_model(model)?, + dimensions: Arc::new(AtomicUsize::new(0)), + options: None, + }) + } + + /// Embeds text for a model whose vector width is not known in advance. + /// + /// The temporary adapter learns and validates the response width internally; + /// callers receive only the resolved width and vectors, so no model with an + /// unstable `dimensions()` or signature escapes this operation. + pub async fn embed_discovering_dimensions( + base_url: &str, + model: &str, + client: reqwest::Client, + texts: &[String], + num_ctx: u32, + num_batch: u32, + ) -> Result<(usize, Vec>)> { + if !texts.iter().any(|text| !text.trim().is_empty()) { + return Err(TinyAgentsError::Validation( + "dynamic embedding dimension discovery requires at least one nonblank input" + .to_string(), + )); + } + let adapter = Self::try_new_unresolved(base_url, model)? + .with_client(client) + .with_context_options(num_ctx, num_batch); + let vectors = adapter.embed(texts).await?; + Ok((adapter.dimensions(), vectors)) + } + pub fn new(base_url: &str, model: &str, dimensions: usize) -> Self { Self::try_new(base_url, model, dimensions).expect("invalid Ollama embedding configuration") } @@ -134,11 +174,25 @@ impl OllamaEmbeddingModel { Ok(output) } - fn validate_dimensions(&self, index: usize, vector: &[f32]) -> Result<()> { - if vector.len() != self.dimensions { + pub(super) fn validate_dimensions(&self, index: usize, vector: &[f32]) -> Result<()> { + if vector.is_empty() { + return Err(TinyAgentsError::Embedding(format!( + "ollama embed returned an empty vector at index {index}" + ))); + } + let expected = match self.dimensions.compare_exchange( + 0, + vector.len(), + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => vector.len(), + Err(expected) => expected, + }; + if vector.len() != expected { return Err(TinyAgentsError::Embedding(format!( "ollama embed dimension mismatch at index {index}: expected {}, got {}", - self.dimensions, + expected, vector.len() ))); } @@ -284,7 +338,7 @@ impl EmbeddingModel for OllamaEmbeddingModel { } fn dimensions(&self) -> usize { - self.dimensions + self.dimensions.load(Ordering::Acquire) } async fn embed(&self, texts: &[String]) -> Result>> { diff --git a/src/harness/embeddings/test.rs b/src/harness/embeddings/test.rs index 42559ec..2583c40 100644 --- a/src/harness/embeddings/test.rs +++ b/src/harness/embeddings/test.rs @@ -69,6 +69,30 @@ async fn mock_model_batches_in_order() { } } +#[test] +fn unresolved_ollama_dimensions_are_learned_and_enforced_internally() { + let model = OllamaEmbeddingModel::try_new_unresolved("http://host:11434", "custom").unwrap(); + assert_eq!(model.dimensions(), 0); + model.validate_dimensions(0, &[0.0; 7]).unwrap(); + assert_eq!(model.dimensions(), 7); + assert!(model.validate_dimensions(1, &[0.0; 8]).is_err()); +} + +#[tokio::test] +async fn dynamic_ollama_discovery_rejects_blank_only_batches() { + let error = OllamaEmbeddingModel::embed_discovering_dimensions( + "http://host:11434", + "custom", + reqwest::Client::new(), + &[" ".to_string()], + 1, + 1, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("nonblank")); +} + #[tokio::test] async fn mock_model_empty_input_returns_empty() { let model = MockEmbeddingModel::new(8); diff --git a/src/harness/host/budget.rs b/src/harness/host/budget.rs new file mode 100644 index 0000000..d0ef203 --- /dev/null +++ b/src/harness/host/budget.rs @@ -0,0 +1,176 @@ +//! Admission control and cost accounting supplied by the embedder. +//! +//! See [`BudgetGate`] for the trait contract and [`UnmeteredBudgetGate`] for +//! the inert default. + +use std::any::Any; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::harness::cost::CostTotals; +use crate::harness::ids::{CallId, RunId, ThreadId}; +use crate::harness::usage::Usage; + +/// Admission control and cost accounting owned by the embedder. +/// +/// The crate already tracks [`Usage`] and [`CostTotals`] and enforces +/// [`RunLimits`][crate::harness::limits::RunLimits]. This trait is the seam for +/// *external* budgets: process-wide concurrency, pricing tables, and durable +/// spend ledgers the crate cannot know about. +/// +/// Scope note: this is money and admission, not context. Fitting a +/// conversation into a model's window is +/// [`Summarizer`][crate::harness::summarization::Summarizer] plus +/// [`RunLimits`][crate::harness::limits::RunLimits]; a host's compaction +/// *profile* is host data and rides in +/// [`AgentDefinition::extras`][super::AgentDefinition::extras]. +#[async_trait] +pub trait BudgetGate: Send + Sync { + /// Waits for permission to start work, returning an opaque lease held for + /// the duration and released on drop. `Ok(None)` means admission was + /// refused without an error (for example, a paused scheduler). + /// + /// The default admits immediately with an inert lease. + async fn acquire( + &self, + state: &State, + request: &AdmissionRequest<'_>, + ) -> Result> { + let _ = (state, request); + Ok(Some(BudgetLease::unmetered())) + } + + /// Prices one model call when the provider did not report a charge. + /// Synchronous: implementations are table lookups, not I/O. + fn estimate_cost(&self, state: &State, model_id: &str, usage: &Usage) -> CostTotals { + let _ = (state, model_id, usage); + CostTotals::default() + } + + /// Records one completed model call against the host's ledger. + async fn record_usage(&self, state: &State, entry: &UsageEntry<'_>) -> Result<()>; + + /// Charges a completed turn and reports whether the run may continue. + /// + /// A [`BudgetVerdict::Stop`] is a graceful request drained at the next + /// iteration boundary, not an abort; a bounded overshoot is expected. + /// The default never stops a run. + async fn account_turn(&self, state: &State, charge: &TurnCharge<'_>) -> Result { + let _ = (state, charge); + Ok(BudgetVerdict::Continue) + } +} + +/// Work asking permission to start. +#[derive(Clone, Debug)] +pub struct AdmissionRequest<'a> { + /// The run about to start. + pub run_id: &'a RunId, + /// Host-defined identity of the agent taking the run. + pub agent_id: &'a str, + /// Host-defined workload label the run will draw against. + pub workload: &'a str, + /// `true` when a user is waiting on the result; hosts commonly prioritise + /// interactive work over scheduled work. + pub interactive: bool, +} + +/// An opaque admission lease. The crate holds it and drops it; it never +/// inspects it, so hosts can carry a semaphore permit, a token bucket handle, +/// or nothing at all. +/// +/// The inner value is intentionally write-only from the crate's perspective: +/// its whole purpose is to be dropped at the right moment. +/// [`into_inner`][Self::into_inner] exists for hosts that need it back. +pub struct BudgetLease(Box); + +impl BudgetLease { + /// Wraps a host guard whose `Drop` releases the admission. + pub fn new(guard: T) -> Self { + Self(Box::new(guard)) + } + + /// A lease that grants everything and releases nothing. + pub fn unmetered() -> Self { + Self::new(()) + } + + /// Returns the wrapped guard, for a host that needs to downcast it. + pub fn into_inner(self) -> Box { + self.0 + } +} + +impl std::fmt::Debug for BudgetLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("BudgetLease(..)") + } +} + +/// One completed model call, ready for the host's ledger. +#[derive(Clone, Debug)] +pub struct UsageEntry<'a> { + /// The run the call belongs to. + pub run_id: &'a RunId, + /// Provider-assigned identity of the call. + pub call_id: &'a CallId, + /// Model that was called. + pub model_id: &'a str, + /// Token counts for the call. + pub usage: Usage, + /// Provider-reported charge when available, otherwise the value returned + /// by [`BudgetGate::estimate_cost`]. + pub cost: CostTotals, +} + +/// One completed turn, ready to be charged. +#[derive(Clone, Debug)] +pub struct TurnCharge<'a> { + /// The run the turn belongs to. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Token counts accumulated over the turn. + pub usage: Usage, + /// Charge accumulated over the turn. + pub cost: CostTotals, + /// Wall-clock duration of the turn. + pub elapsed_secs: u64, +} + +/// Whether the run may continue after a charge. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "verdict")] +pub enum BudgetVerdict { + /// The run may keep going. + Continue, + /// The run should wind down. Host-authored reason, surfaced to the caller + /// verbatim. + Stop { + /// Host-authored explanation. + reason: String, + }, +} + +/// A [`BudgetGate`] with no budget behind it. +/// +/// `record_usage` accepts and discards; every other method takes its trait +/// default. Wiring it is observationally identical to running ungated. +#[derive(Clone, Copy, Debug, Default)] +pub struct UnmeteredBudgetGate; + +impl UnmeteredBudgetGate { + /// Creates the gate. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl BudgetGate for UnmeteredBudgetGate { + async fn record_usage(&self, _state: &State, _entry: &UsageEntry<'_>) -> Result<()> { + Ok(()) + } +} diff --git a/src/harness/host/context.rs b/src/harness/host/context.rs new file mode 100644 index 0000000..f35cfb8 --- /dev/null +++ b/src/harness/host/context.rs @@ -0,0 +1,206 @@ +//! System-prompt and per-turn context assembly supplied by the embedder. +//! +//! See [`ContextComposer`] for the trait contract and +//! [`PassthroughContextComposer`] for the inert default. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::Result; +use crate::harness::ids::{RunId, ThreadId}; +use crate::harness::tool::ToolSchema; +use crate::harness::workspace::WorkspaceDescriptor; + +/// Builds the run's system prompt and the ordered, per-turn context fragments +/// that precede user input. +/// +/// This is the typed turn-preparation seam: enrichment becomes an ordered set +/// of registered fragments with placement and priority instead of a bespoke +/// method body. The crate owns ordering, placement, and assembly; the embedder +/// owns every byte of text. +#[async_trait] +pub trait ContextComposer: Send + Sync { + /// Renders the run's system prompt. Called once per run so the prompt + /// prefix stays byte-stable across turns for provider prompt caching. + /// + /// Synchronous by contract. Every input is already in hand at the call + /// site, and hosts have cold-boot resume paths that build a prompt without + /// being allowed to fan out to a memory store; an `async` signature would + /// make that fan-out structurally unavoidable. Per-turn work that genuinely + /// needs I/O belongs in [`prepare_turn`][Self::prepare_turn]. + fn compose_system_prompt( + &self, + state: &State, + request: &SystemPromptRequest<'_>, + ) -> Result; + + /// Produces this turn's context fragments. + /// + /// The default returns nothing, so a host that only needs a system prompt + /// implements one method. + async fn prepare_turn( + &self, + state: &State, + request: &TurnPreparationRequest<'_>, + ) -> Result { + let _ = (state, request); + Ok(TurnPreparation::default()) + } +} + +/// Everything a composer may consult when rendering the run's system prompt. +#[derive(Clone, Debug)] +pub struct SystemPromptRequest<'a> { + /// The run being started. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Host-defined identity of the agent being instantiated. + pub agent_id: &'a str, + /// Model the run will call. + pub model_id: &'a str, + /// Full schemas for the tools registered on the run. + pub tools: &'a [ToolSchema], + /// Names the policy layer decided to advertise; see + /// [`SecurityGate::filter_tools`][super::SecurityGate::filter_tools]. + pub visible_tool_names: &'a [String], + /// Dispatcher-rendered instructions for invoking tools, when the model is + /// driven prompt-guided rather than with native tool calls. + pub tool_call_instructions: &'a str, + /// The environment the run may touch, when one was prepared. Carries the + /// primary root plus any additional trusted roots; the crate models no + /// other named root. + pub workspace: Option<&'a WorkspaceDescriptor>, +} + +/// Everything a composer may consult when preparing one turn. +#[derive(Clone, Debug)] +pub struct TurnPreparationRequest<'a> { + /// The run this turn belongs to. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Host-defined identity of the agent taking the turn. + pub agent_id: &'a str, + /// The user input this turn will act on. + pub input: &'a str, + /// Zero-based position of this turn within the run. + pub turn_index: u32, + /// `true` when no prior turn exists in this run's history. + pub first_turn: bool, + /// `true` when history was seeded from durable storage rather than built + /// in-process; a first turn of a resumed thread is both. + pub resumed: bool, +} + +/// The fragments one turn contributes, plus opaque host passthrough. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct TurnPreparation { + /// Fragments to splice into the request, in any order — the runtime sorts + /// them by placement and priority. + #[serde(default)] + pub blocks: Vec, + /// Opaque host payload returned to the caller untouched. + /// + /// Deliberately untyped: presentation contracts (source attribution chips, + /// cost footers, timeline rows) are the embedder's, and the crate must not + /// grow a rendering opinion by modelling them. + #[serde(default, skip_serializing_if = "Value::is_null")] + pub extras: Value, +} + +impl TurnPreparation { + /// Creates a preparation carrying `blocks` and no extras. + pub fn new(blocks: Vec) -> Self { + Self { + blocks, + extras: Value::Null, + } + } + + /// Attaches an opaque host payload. + pub fn with_extras(mut self, extras: Value) -> Self { + self.extras = extras; + self + } +} + +/// One rendered fragment of turn context. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContextBlock { + /// Stable identity for the fragment, used for de-duplication and logging. + pub id: String, + /// Host-authored text, spliced in verbatim. + pub body: String, + /// Where the fragment is spliced into the request. + #[serde(default)] + pub placement: ContextPlacement, + /// Higher sorts earlier within a placement; ties keep insertion order. + #[serde(default)] + pub priority: i32, +} + +impl ContextBlock { + /// Creates a [`ContextPlacement::TurnPrefix`] block at priority `0`. + pub fn new(id: impl Into, body: impl Into) -> Self { + Self { + id: id.into(), + body: body.into(), + placement: ContextPlacement::default(), + priority: 0, + } + } + + /// Sets where the fragment is spliced in. + pub fn with_placement(mut self, placement: ContextPlacement) -> Self { + self.placement = placement; + self + } + + /// Sets the sort priority within the fragment's placement. + pub fn with_priority(mut self, priority: i32) -> Self { + self.priority = priority; + self + } +} + +/// Where a fragment is spliced into the request. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextPlacement { + /// Prepended to the turn's user message. Per-turn content belongs here: + /// putting it in the system prompt invalidates the cached prefix, and + /// history trimming commonly hoists system messages to the front, which + /// reorders content that was meant to ride one specific turn. + #[default] + TurnPrefix, + /// Prepended to the system prompt. Run-stable content only. + SystemPrefix, +} + +/// A [`ContextComposer`] that contributes nothing. +/// +/// `compose_system_prompt` returns an empty string and `prepare_turn` takes the +/// trait default, so composing it is observationally identical to composing no +/// composer at all. +#[derive(Clone, Copy, Debug, Default)] +pub struct PassthroughContextComposer; + +impl PassthroughContextComposer { + /// Creates the composer. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ContextComposer for PassthroughContextComposer { + fn compose_system_prompt( + &self, + _state: &State, + _request: &SystemPromptRequest<'_>, + ) -> Result { + Ok(String::new()) + } +} diff --git a/src/harness/host/definition.rs b/src/harness/host/definition.rs new file mode 100644 index 0000000..f765bd6 --- /dev/null +++ b/src/harness/host/definition.rs @@ -0,0 +1,231 @@ +//! The agent definitions a runtime can instantiate. +//! +//! See [`DefinitionRegistry`] for the trait contract, [`AgentDefinition`] for +//! the deliberately minimal crate-owned shape, and +//! [`InMemoryDefinitionRegistry`] for the default. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{Result, TinyAgentsError}; + +/// A crate-owned, product-neutral description of one instantiable agent. +/// +/// This type is deliberately **small**. It carries only what a generic runtime +/// must read to stand an agent up — an identity, some text, a model hint, and a +/// tool list — and pushes everything else into [`extras`][Self::extras], an +/// opaque payload the crate never inspects. Host concepts such as compaction +/// profiles, delegation overrides, prompt-source indirection, and workspace +/// layout live there or in the host's own richer definition type, which maps +/// *into* this one at registration time. +/// +/// The narrowness is the design, not a placeholder. A published type that grows +/// a field per host concept becomes a breaking change every time the host +/// learns something new, and it drags host vocabulary onto docs.rs. Adding an +/// entry to `extras` costs the crate nothing and breaks no downstream build. +/// +/// [`Default`] is implemented so hosts can construct with struct-update syntax +/// (`AgentDefinition { id, ..Default::default() }`) and keep compiling if the +/// crate ever does add a field. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AgentDefinition { + /// Host-defined identity, unique within a registry. + pub id: String, + /// Short human-readable description of what this agent is for. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Fully rendered system prompt, when the definition carries one directly. + /// + /// Plain text on purpose: a host whose prompt is assembled from layers, + /// files, or a function does that assembly on its side and registers the + /// result, or renders it through + /// [`ContextComposer`][super::ContextComposer] instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Model this agent prefers, when the definition pins one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Names of the tools this agent should be given. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + /// Opaque host payload carried through untouched. + #[serde(default, skip_serializing_if = "Value::is_null")] + pub extras: Value, +} + +impl AgentDefinition { + /// Creates a definition with only an id. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + ..Default::default() + } + } + + /// Sets the human-readable description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets the fully rendered system prompt. + pub fn with_system_prompt(mut self, prompt: impl Into) -> Self { + self.system_prompt = Some(prompt.into()); + self + } + + /// Pins the preferred model. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + /// Sets the tool names this agent should be given. + pub fn with_tools(mut self, tools: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.tools = tools.into_iter().map(Into::into).collect(); + self + } + + /// Attaches the opaque host payload. + pub fn with_extras(mut self, extras: Value) -> Self { + self.extras = extras; + self + } +} + +/// Supplies the agent definitions a runtime can instantiate. +/// +/// The crate owns the [`AgentDefinition`] *type*; this trait owns where +/// instances come from — bundled sets, on-disk files, or host configuration. +/// It is deliberately read-only: mutation, validation, and precedence between +/// sources are the embedder's concern and happen before `list` returns. +#[async_trait] +pub trait DefinitionRegistry: Send + Sync { + /// Returns the definition registered under `id`, or `Ok(None)` when no + /// definition claims it. An unknown id is not an error. + async fn get(&self, state: &State, id: &str) -> Result>; + + /// Returns every definition, in the embedder's preferred order. + async fn list(&self, state: &State) -> Result>; + + /// The id to instantiate when a caller names none. + /// + /// Defaults to `None`; the crate ships no default agent identity, so the + /// embedder names it here rather than the crate hard-coding one. + async fn default_id(&self, state: &State) -> Result> { + let _ = state; + Ok(None) + } +} + +/// The map plus insertion order backing [`InMemoryDefinitionRegistry`]. +#[derive(Default)] +struct DefinitionIndex { + /// `id → definition`. + by_id: HashMap, + /// Ids in insertion order, so `list` is stable. + order: Vec, + /// Id returned by [`DefinitionRegistry::default_id`]. + default_id: Option, +} + +/// Ephemeral, in-process [`DefinitionRegistry`]. +/// +/// Constructed empty, so `list` returns `vec![]`, `get` returns `None`, and +/// `default_id` returns `None` until a host populates it. Clones share the same +/// underlying data through the inner [`Arc`]; there is no durability. +#[derive(Clone, Default)] +pub struct InMemoryDefinitionRegistry { + inner: Arc>, +} + +impl InMemoryDefinitionRegistry { + /// Creates a new, empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Registers `definition`, replacing any prior entry with the same id and + /// keeping the original insertion position. + pub fn insert(&self, definition: AgentDefinition) -> Result<()> { + let mut index = self.lock()?; + if !index.by_id.contains_key(&definition.id) { + index.order.push(definition.id.clone()); + } + index.by_id.insert(definition.id.clone(), definition); + Ok(()) + } + + /// Builder form of [`insert`](Self::insert). + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. Use + /// [`insert`](Self::insert) where a `Result` is preferred. + pub fn with_definition(self, definition: AgentDefinition) -> Self { + self.insert(definition) + .expect("definition registry lock poisoned"); + self + } + + /// Sets the id returned by [`DefinitionRegistry::default_id`]. + pub fn set_default_id(&self, id: impl Into) -> Result<()> { + self.lock()?.default_id = Some(id.into()); + Ok(()) + } + + /// Builder form of [`set_default_id`](Self::set_default_id). + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. + pub fn with_default_id(self, id: impl Into) -> Self { + self.set_default_id(id) + .expect("definition registry lock poisoned"); + self + } + + /// Returns the number of registered definitions. + pub fn len(&self) -> Result { + Ok(self.lock()?.order.len()) + } + + /// Returns `true` when no definitions are registered. + pub fn is_empty(&self) -> Result { + Ok(self.lock()?.order.is_empty()) + } + + fn lock(&self) -> Result> { + self.inner.lock().map_err(|e| { + TinyAgentsError::Validation(format!("definition registry lock poisoned: {e}")) + }) + } +} + +#[async_trait] +impl DefinitionRegistry for InMemoryDefinitionRegistry { + async fn get(&self, _state: &State, id: &str) -> Result> { + Ok(self.lock()?.by_id.get(id).cloned()) + } + + async fn list(&self, _state: &State) -> Result> { + let index = self.lock()?; + Ok(index + .order + .iter() + .filter_map(|id| index.by_id.get(id).cloned()) + .collect()) + } + + async fn default_id(&self, _state: &State) -> Result> { + Ok(self.lock()?.default_id.clone()) + } +} diff --git a/src/harness/host/experience.rs b/src/harness/host/experience.rs new file mode 100644 index 0000000..0221246 --- /dev/null +++ b/src/harness/host/experience.rs @@ -0,0 +1,108 @@ +//! Retrieval over prior-run outcomes the embedder has chosen to retain. +//! +//! See [`ExperienceStore`] for the trait contract and [`NoopExperienceStore`] +//! for the inert default. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::Result; + +/// Retrieval over prior-run outcomes the embedder has chosen to retain. +/// +/// Distinct from [`MemoryProvider`][super::MemoryProvider], which serves the +/// user's corpus: this serves the agent's own record of what worked. The crate +/// stores nothing and renders nothing — it hands back `body` verbatim for a +/// [`ContextComposer`][super::ContextComposer] to place. +#[async_trait] +pub trait ExperienceStore: Send + Sync { + /// Returns at most `max_hits` prior outcomes relevant to `query`. + /// + /// Hits with an empty `match_reasons` carry no evidence for why they were + /// selected and callers may drop them. + async fn retrieve( + &self, + state: &State, + query: &ExperienceQuery<'_>, + ) -> Result>; + + /// Offers candidate outcomes for retention. The default discards them, so + /// a read-only embedder implements one method. + async fn record(&self, state: &State, entries: Vec) -> Result<()> { + let _ = (state, entries); + Ok(()) + } +} + +/// A relevance query against retained prior-run outcomes. +#[derive(Clone, Debug, Default)] +pub struct ExperienceQuery<'a> { + /// Free text the backend scores outcomes against. + pub text: &'a str, + /// Restrict to outcomes recorded by one agent. + pub agent_id: Option<&'a str>, + /// Host-defined label for how the run was entered. + pub entrypoint: Option<&'a str>, + /// Opaque partition key. `None` searches every partition. + pub partition: Option<&'a str>, + /// Tools available to this run; backends may weight hits that used them. + pub tool_names: &'a [String], + /// Maximum hits to return. + pub max_hits: usize, +} + +/// One retained outcome, already rendered by the host. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExperienceHit { + /// Backend-assigned identity of the outcome. + pub id: String, + /// Host-rendered text, used verbatim. + pub body: String, + /// Relevance score, when the backend computes one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score: Option, + /// Why this hit matched, as host-defined labels. + #[serde(default)] + pub match_reasons: Vec, +} + +/// A candidate outcome offered for retention. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExperienceEntry { + /// Host-defined identity for the outcome. + pub id: String, + /// Agent that produced it, when attributed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Opaque partition key to file it under. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub partition: Option, + /// Opaque host payload; the crate never inspects it. + pub payload: Value, +} + +/// An [`ExperienceStore`] that retains nothing and returns nothing. +/// +/// Zero state, zero allocation: `retrieve` returns an empty `Vec` and `record` +/// takes the discarding trait default. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopExperienceStore; + +impl NoopExperienceStore { + /// Creates the store. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ExperienceStore for NoopExperienceStore { + async fn retrieve( + &self, + _state: &State, + _query: &ExperienceQuery<'_>, + ) -> Result> { + Ok(Vec::new()) + } +} diff --git a/src/harness/host/learning.rs b/src/harness/host/learning.rs new file mode 100644 index 0000000..2b02919 --- /dev/null +++ b/src/harness/host/learning.rs @@ -0,0 +1,113 @@ +//! Completed work handed to the embedder for durable knowledge extraction. +//! +//! See [`LearningSink`] for the trait contract and [`NoopLearningSink`] for the +//! inert default. + +use std::path::Path; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::Result; +use crate::harness::ids::{RunId, ThreadId}; + +/// Receives completed work so the embedder can derive durable knowledge from +/// it. +/// +/// Fire-and-forget by contract: the runtime does not wait on the result and +/// treats an `Err` as a logged failure, never a run failure. Implementations +/// must return promptly and defer real work to a background task. +#[async_trait] +pub trait LearningSink: Send + Sync { + /// Called once after a turn produces its final output. + async fn on_turn_completed(&self, state: &State, record: &TurnRecord) -> Result<()>; + + /// Called after a turn's messages are durably written, naming the artifact + /// so an ingester can read it without holding the messages in memory. + /// + /// The default does nothing. + async fn on_transcript_committed( + &self, + state: &State, + commit: &TranscriptCommit<'_>, + ) -> Result<()> { + let _ = (state, commit); + Ok(()) + } +} + +/// One completed turn, summarized for downstream extraction. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TurnRecord { + /// The run the turn belongs to. + pub run_id: RunId, + /// Thread the run belongs to, when it belongs to one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + /// Host-defined identity of the agent that took the turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Host-defined label for how the run was entered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entrypoint: Option, + /// The user input that opened the turn. + pub input: String, + /// The assistant's final output for the turn. + pub output: String, + /// Tool calls made during the turn, in call order. + #[serde(default)] + pub tool_calls: Vec, + /// Number of model calls the turn consumed. + pub model_calls: u32, + /// Wall-clock duration of the turn. + pub elapsed_ms: u64, +} + +/// One tool call within a [`TurnRecord`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToolOutcomeRecord { + /// Name of the tool that ran. + pub name: String, + /// Arguments the model supplied. + pub arguments: Value, + /// Whether the call succeeded. + pub succeeded: bool, + /// Bounded, non-sensitive description of the result. Never raw output. + pub summary: String, + /// Wall-clock duration of the call. + pub elapsed_ms: u64, +} + +/// A durably written transcript, named rather than inlined. +#[derive(Clone, Debug)] +pub struct TranscriptCommit<'a> { + /// The run whose messages were written. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Where the transcript lives on disk. + pub path: &'a Path, + /// How many messages this commit appended. + pub appended_messages: u64, +} + +/// A [`LearningSink`] that learns nothing. +/// +/// Registering it is indistinguishable from registering nothing. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopLearningSink; + +impl NoopLearningSink { + /// Creates the sink. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl LearningSink for NoopLearningSink { + async fn on_turn_completed(&self, _state: &State, _record: &TurnRecord) -> Result<()> { + Ok(()) + } +} diff --git a/src/harness/host/memory.rs b/src/harness/host/memory.rs new file mode 100644 index 0000000..8db9880 --- /dev/null +++ b/src/harness/host/memory.rs @@ -0,0 +1,395 @@ +//! Retrieval-oriented long-term memory supplied by the embedder. +//! +//! See [`MemoryProvider`] for the trait contract and +//! [`InMemoryMemoryProvider`] for the inert default. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{Result, TinyAgentsError}; +use crate::harness::ids::ThreadId; + +/// Number of records [`MemoryProvider::recall`] returns when +/// [`MemoryQuery::limit`] is `None`. +pub const DEFAULT_RECALL_LIMIT: usize = 10; + +/// Retrieval-oriented long-term memory supplied by the embedder. +/// +/// This is **not** conversation history: thread message logs are +/// [`ChatHistory`][crate::harness::memory::ChatHistory] and opaque key/value or +/// append-only streams are [`Store`][crate::harness::store::Store] / +/// [`AppendStore`][crate::harness::store::AppendStore]. A `MemoryProvider` +/// answers "what does the host know that is relevant to this text?" over a +/// namespaced corpus with scoring; it never replays, rewrites, or compacts a +/// thread's message list. +#[async_trait] +pub trait MemoryProvider: Send + Sync { + /// Returns scored records relevant to `query`, most relevant first. + /// + /// An empty or non-matching query yields `Ok(vec![])`, never an error. + async fn recall(&self, state: &State, query: &MemoryQuery<'_>) -> Result>; + + /// Returns records matching an exact scope, without relevance scoring. + async fn list(&self, state: &State, filter: &MemoryFilter<'_>) -> Result>; + + /// Inserts or overwrites one record, keyed by `(namespace, key)`. + async fn write(&self, state: &State, record: MemoryWrite) -> Result<()>; + + /// Returns a bounded per-namespace digest of the corpus. + /// + /// The default returns an empty `Vec`; backends without a rollup layer opt + /// out rather than erroring. + async fn namespace_digests( + &self, + state: &State, + caps: DigestCaps, + ) -> Result> { + let _ = (state, caps); + Ok(Vec::new()) + } +} + +/// A relevance query against the host's corpus. +#[derive(Clone, Debug, Default)] +pub struct MemoryQuery<'a> { + /// Free text the backend scores records against. + pub text: &'a str, + /// Maximum records to return. `None` means the backend's own default + /// (`DEFAULT_RECALL_LIMIT` for the in-crate implementation). + pub limit: Option, + /// Restrict to one namespace. `None` searches every namespace. + pub namespace: Option<&'a str>, + /// Thread this query runs inside; combined with `cross_thread` it selects + /// same-thread, other-thread, or unscoped recall. + pub thread_id: Option<&'a ThreadId>, + /// When `true`, records from other threads are eligible. When `false` and + /// `thread_id` is set, only that thread's records are eligible. + pub cross_thread: bool, + /// Drop hits scoring below this value. + pub min_score: Option, +} + +impl<'a> MemoryQuery<'a> { + /// Creates a query over `text` with every other field at its default. + pub fn new(text: &'a str) -> Self { + Self { + text, + ..Default::default() + } + } + + /// Sets the maximum number of records to return. + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + /// Restricts the query to one namespace. + pub fn with_namespace(mut self, namespace: &'a str) -> Self { + self.namespace = Some(namespace); + self + } + + /// Scopes the query to `thread_id`, optionally allowing other threads in. + pub fn with_thread(mut self, thread_id: &'a ThreadId, cross_thread: bool) -> Self { + self.thread_id = Some(thread_id); + self.cross_thread = cross_thread; + self + } + + /// Drops hits scoring below `min_score`. + pub fn with_min_score(mut self, min_score: f64) -> Self { + self.min_score = Some(min_score); + self + } +} + +/// An exact-match scope for [`MemoryProvider::list`]. +/// +/// Distinct from [`crate::harness::memory::MemoryScope`], which labels the +/// short-term/long-term conversation layers rather than selecting records. +#[derive(Clone, Debug, Default)] +pub struct MemoryFilter<'a> { + /// Restrict to one namespace. `None` matches every namespace. + pub namespace: Option<&'a str>, + /// Free-form host category label; the crate assigns it no meaning. + pub category: Option<&'a str>, + /// Restrict to records recorded against one thread. + pub thread_id: Option<&'a ThreadId>, + /// Maximum records to return. `None` returns every match. + pub limit: Option, +} + +impl<'a> MemoryFilter<'a> { + /// Creates a filter matching every record. + pub fn new() -> Self { + Self::default() + } + + /// Restricts the filter to one namespace. + pub fn with_namespace(mut self, namespace: &'a str) -> Self { + self.namespace = Some(namespace); + self + } + + /// Restricts the filter to one host category label. + pub fn with_category(mut self, category: &'a str) -> Self { + self.category = Some(category); + self + } +} + +/// One record returned by [`MemoryProvider::recall`] or +/// [`MemoryProvider::list`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MemoryRecord { + /// Backend-assigned identity, stable for the life of the record. + pub id: String, + /// Host-assigned key, unique within `namespace`. + pub key: String, + /// The stored text. + pub content: String, + /// Namespace the record belongs to, when the backend tracks one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Free-form host category label. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Thread the record was recorded against, when thread-scoped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + /// Relevance score for this hit. Absent for unscored `list` results. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score: Option, + /// Backend-formatted timestamp, when known. The crate never parses it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recorded_at: Option, + /// Backend-defined extras carried opaquely so hosts can round-trip + /// provenance without the crate modelling it. + #[serde(default)] + pub attributes: Value, +} + +/// An insert-or-overwrite request, keyed by `(namespace, key)`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MemoryWrite { + /// Namespace the record belongs to. + pub namespace: String, + /// Key, unique within `namespace`. Writing an existing key overwrites it. + pub key: String, + /// The text to store. + pub content: String, + /// Free-form host category label. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Thread to record this against, when thread-scoped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, +} + +impl MemoryWrite { + /// Creates a write with no category and no thread scope. + pub fn new( + namespace: impl Into, + key: impl Into, + content: impl Into, + ) -> Self { + Self { + namespace: namespace.into(), + key: key.into(), + content: content.into(), + category: None, + thread_id: None, + } + } + + /// Tags the write with a host category label. + pub fn with_category(mut self, category: impl Into) -> Self { + self.category = Some(category.into()); + self + } + + /// Scopes the write to one thread. + pub fn with_thread_id(mut self, thread_id: impl Into) -> Self { + self.thread_id = Some(thread_id.into()); + self + } +} + +/// Size ceilings for [`MemoryProvider::namespace_digests`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DigestCaps { + /// Maximum characters any single namespace digest may contribute. + pub per_namespace_max_chars: usize, + /// Maximum characters the whole digest set may contribute. + pub total_max_chars: usize, +} + +/// A bounded rollup of one namespace. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NamespaceDigest { + /// Namespace this digest summarizes. + pub namespace: String, + /// Host-rendered digest text, used verbatim. + pub body: String, + /// Backend-formatted timestamp of the last update, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// Ephemeral, in-process [`MemoryProvider`] backed by a shared map. +/// +/// Mirrors [`InMemoryChatHistory`][crate::harness::memory::InMemoryChatHistory] +/// in shape and durability guarantees (there are none). Clones share the same +/// underlying data through the inner [`Arc`]. +/// +/// Scoring is deliberately trivial and dependency-free: the query is split on +/// whitespace and each record scores `matched_tokens / total_tokens` by +/// case-insensitive substring containment. Ties break on `key` ascending so +/// results are deterministic despite the backing [`HashMap`]. +/// [`MemoryProvider::namespace_digests`] takes the trait default (empty) — this +/// backend has no rollup layer. +#[derive(Clone, Default)] +pub struct InMemoryMemoryProvider { + /// `(namespace, key) → record` map protected by a standard mutex. + records: Arc>>, +} + +impl InMemoryMemoryProvider { + /// Creates a new, empty provider. + pub fn new() -> Self { + Self::default() + } + + /// Returns the number of stored records. + pub fn len(&self) -> Result { + Ok(self.lock()?.len()) + } + + /// Returns `true` when no records are stored. + pub fn is_empty(&self) -> Result { + Ok(self.lock()?.is_empty()) + } + + /// Removes every stored record. + pub fn clear(&self) -> Result<()> { + self.lock()?.clear(); + Ok(()) + } + + fn lock(&self) -> Result>> { + self.records + .lock() + .map_err(|e| TinyAgentsError::Memory(format!("memory provider lock poisoned: {e}"))) + } + + /// Returns `true` when `record` is eligible for a query scoped to + /// `thread_id` with the given `cross_thread` setting. + fn thread_matches(record: &MemoryRecord, thread_id: Option<&ThreadId>, cross: bool) -> bool { + match thread_id { + // Unscoped, or explicitly allowed to reach other threads. + None => true, + Some(_) if cross => true, + Some(wanted) => record.thread_id.as_deref() == Some(wanted.as_str()), + } + } +} + +#[async_trait] +impl MemoryProvider for InMemoryMemoryProvider { + async fn recall(&self, _state: &State, query: &MemoryQuery<'_>) -> Result> { + let tokens: Vec = query + .text + .split_whitespace() + .map(|token| token.to_lowercase()) + .collect(); + // Short-circuit before any division: an empty query has no signal, and + // the contract says that is an empty result rather than an error. + if tokens.is_empty() { + return Ok(Vec::new()); + } + let total = tokens.len() as f64; + + let mut hits: Vec = self + .lock()? + .values() + .filter(|record| match query.namespace { + Some(namespace) => record.namespace.as_deref() == Some(namespace), + None => true, + }) + .filter(|record| Self::thread_matches(record, query.thread_id, query.cross_thread)) + .filter_map(|record| { + let haystack = record.content.to_lowercase(); + let matched = tokens + .iter() + .filter(|token| haystack.contains(token.as_str())) + .count(); + if matched == 0 { + return None; + } + let score = matched as f64 / total; + if query.min_score.is_some_and(|floor| score < floor) { + return None; + } + let mut hit = record.clone(); + hit.score = Some(score); + Some(hit) + }) + .collect(); + + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.key.cmp(&b.key)) + }); + hits.truncate(query.limit.unwrap_or(DEFAULT_RECALL_LIMIT)); + Ok(hits) + } + + async fn list(&self, _state: &State, filter: &MemoryFilter<'_>) -> Result> { + let mut matches: Vec = self + .lock()? + .values() + .filter(|record| match filter.namespace { + Some(namespace) => record.namespace.as_deref() == Some(namespace), + None => true, + }) + .filter(|record| match filter.category { + Some(category) => record.category.as_deref() == Some(category), + None => true, + }) + .filter(|record| match filter.thread_id { + Some(thread_id) => record.thread_id.as_deref() == Some(thread_id.as_str()), + None => true, + }) + .cloned() + .collect(); + + matches.sort_by(|a, b| a.key.cmp(&b.key)); + if let Some(limit) = filter.limit { + matches.truncate(limit); + } + Ok(matches) + } + + async fn write(&self, _state: &State, record: MemoryWrite) -> Result<()> { + let stored = MemoryRecord { + id: format!("{}/{}", record.namespace, record.key), + key: record.key.clone(), + content: record.content, + namespace: Some(record.namespace.clone()), + category: record.category, + thread_id: record.thread_id, + score: None, + recorded_at: None, + attributes: Value::Null, + }; + self.lock()?.insert((record.namespace, record.key), stored); + Ok(()) + } +} diff --git a/src/harness/host/mod.rs b/src/harness/host/mod.rs new file mode 100644 index 0000000..784240e --- /dev/null +++ b/src/harness/host/mod.rs @@ -0,0 +1,120 @@ +//! Host capability seams — the extension points an embedding application fills. +//! +//! Every other harness module names a concern the crate itself implements: +//! [`memory`][crate::harness::memory] ships [`InMemoryChatHistory`][crate::harness::memory::InMemoryChatHistory], +//! [`model`][crate::harness::model] ships real providers, +//! [`store`][crate::harness::store] ships a file-backed store. This module is +//! the opposite axis: points where the crate deliberately has **no** opinion +//! and ships only inert defaults, so an embedder can supply retrieval, policy, +//! budgets, routing, and delivery without the runtime importing any of it. +//! +//! The traits follow the same generic-over-`State` pattern as +//! [`Tool`][crate::harness::tool::Tool] and +//! [`ChatModel`][crate::harness::model::ChatModel]: the embedder's application +//! state is threaded to every call, so an implementation reaches its own +//! services through `&State` rather than through crate-visible globals. +//! +//! | Trait | Question it answers | +//! |-------|---------------------| +//! | [`MemoryProvider`] | What does the host know that is relevant to this text? | +//! | [`ContextComposer`] | What text precedes the model request this turn? | +//! | [`SecurityGate`] | Is this tool, path, input, or call permitted? | +//! | [`BudgetGate`] | May this work start, what did it cost, may it continue? | +//! | [`DefinitionRegistry`] | Which agents exist and which is the default? | +//! | [`ExperienceStore`] | What did prior runs of this shape learn? | +//! | [`LearningSink`] | Here is completed work — derive what you like from it. | +//! | [`ProgressSink`] | Deliver run events to an out-of-process consumer. | +//! | [`ToolOutcomeClassifier`] | What kind of failure was that? | +//! | [`ModelResolver`] | Which models should this unit of work use? | +//! +//! # Async only where the work is async +//! +//! Methods are `async` only when a realistic implementation performs I/O. +//! Lookups, lexical path checks, pricing tables, and failure classification are +//! plain `fn`, mirroring the sync [`ChatModel::profile`][crate::harness::model::ChatModel::profile] +//! next to the async [`ChatModel::invoke`][crate::harness::model::ChatModel::invoke]. +//! Making a seam `async` forces every caller up the stack to become `async` +//! too, which is a real cost paid by the embedder, not a stylistic choice. +//! +//! # Configuration is not a seam here +//! +//! There is no `ConfigProvider`. Crate-side configuration is expressed as +//! ordinary crate-owned structs the embedder populates when it builds a run. +//! Where a host genuinely needs a *live* value re-read mid-session (a toggle +//! that must take effect without rebuilding), the vehicle is `State`: every +//! method on every trait here receives `&State`, so a host that parks a +//! reloadable handle in its state type keeps read-at-call-time semantics with +//! no crate surface at all. +//! +//! # Scope boundaries worth stating once +//! +//! - [`MemoryProvider`] is **not** conversation history. Thread message logs +//! are [`ChatHistory`][crate::harness::memory::ChatHistory]; opaque key/value +//! and append-only streams are [`Store`][crate::harness::store::Store] and +//! [`AppendStore`][crate::harness::store::AppendStore]. Nothing in this +//! module reads, rewrites, or compacts a transcript. +//! - [`SecurityGate`] decides what is permitted *inside* an environment; +//! [`WorkspaceIsolation`][crate::harness::workspace::WorkspaceIsolation] +//! prepares the environment itself. +//! - [`ProgressSink`] carries the crate's own +//! [`EventRecord`][crate::harness::events::EventRecord]. Projecting that into +//! a presentation model is the embedder's job and stays outside the crate. +//! - [`BudgetGate`] is cost and admission only. Context-window compaction is +//! [`Summarizer`][crate::harness::summarization::Summarizer] plus +//! [`RunLimits`][crate::harness::limits::RunLimits]; a host compaction +//! *profile* is host data and rides in [`AgentDefinition::extras`]. +//! - [`DefinitionRegistry`] supplies agent definitions. Workspace layout is +//! [`WorkspaceIsolation`][crate::harness::workspace::WorkspaceIsolation], +//! prompt personality is [`ContextComposer`], and profile-scoped retrieval is +//! [`MemoryQuery::namespace`] / [`ExperienceQuery::partition`]. A host +//! "profile" concept typically decomposes across those three rather than +//! living here. +//! +//! # Example +//! +//! ``` +//! use tinyagents::harness::host::{MemoryProvider, InMemoryMemoryProvider, MemoryQuery, MemoryWrite}; +//! +//! # tokio::runtime::Runtime::new().unwrap().block_on(async { +//! let memory = InMemoryMemoryProvider::new(); +//! memory +//! .write( +//! &(), +//! MemoryWrite::new("preferences", "tone", "prefers terse answers"), +//! ) +//! .await +//! .unwrap(); +//! +//! let hits = memory +//! .recall(&(), &MemoryQuery::new("terse answers")) +//! .await +//! .unwrap(); +//! assert_eq!(hits.len(), 1); +//! assert_eq!(hits[0].key, "tone"); +//! # }); +//! ``` + +mod budget; +mod context; +mod definition; +mod experience; +mod learning; +mod memory; +mod model; +mod progress; +mod security; +mod tool_outcome; + +pub use budget::*; +pub use context::*; +pub use definition::*; +pub use experience::*; +pub use learning::*; +pub use memory::*; +pub use model::*; +pub use progress::*; +pub use security::*; +pub use tool_outcome::*; + +#[cfg(test)] +mod test; diff --git a/src/harness/host/model.rs b/src/harness/host/model.rs new file mode 100644 index 0000000..3f7c4fd --- /dev/null +++ b/src/harness/host/model.rs @@ -0,0 +1,125 @@ +//! Host-side model routing and construction. +//! +//! See [`ModelResolver`] for the trait contract and [`StaticModelResolver`] for +//! the single-model default. + +use std::sync::Arc; + +use crate::error::Result; +use crate::harness::ids::RunId; +use crate::harness::model::{ChatModel, ModelProfile, ModelRegistry}; + +/// Builds the model set for one run. +/// +/// [`ChatModel`] is how a model is *called*; this is how one is *chosen and +/// constructed*, for embedders whose routing depends on configuration the crate +/// cannot see. It returns a populated [`ModelRegistry`], so the crate's own +/// selection, fallback, and capability checks run unchanged on top of the +/// host's routing decision. +/// +/// Every method is synchronous. Resolution is a configuration read plus a +/// client construction, and capability/window questions are table lookups; a +/// host that must fetch a catalog over the network does so when it builds its +/// state, not on the hot path. Keeping the seam sync is what lets it be called +/// from synchronous session assembly without turning that whole path `async`. +pub trait ModelResolver: Send + Sync { + /// Builds the registry for `request`: the primary model as the registry + /// default, plus any additional named routes the run may select. + /// + /// Called once per run — the returned registry is a fresh allocation, and + /// rebuilding it per turn is waste, not caching policy. + fn resolve(&self, state: &State, request: &ModelResolution<'_>) + -> Result>; + + /// Returns a model's capability profile without constructing it. + /// + /// Lets a caller answer "does this model do native tool calls / accept + /// images?" before a registry exists, and lets an embedder override a + /// provider-reported capability from its own configuration. Defaults to + /// `None` (unknown). + fn profile(&self, state: &State, model_id: &str) -> Result> { + let _ = (state, model_id); + Ok(None) + } + + /// Returns a model's effective input-token window, when known. Defaults to + /// `None`, in which case no window-driven compaction is scheduled. + fn context_window(&self, state: &State, model_id: &str) -> Result> { + let _ = (state, model_id); + Ok(None) + } +} + +/// What the runtime knows about the work a model is being resolved for. +#[derive(Clone, Debug)] +pub struct ModelResolution<'a> { + /// The run being started. + pub run_id: &'a RunId, + /// Host-defined identity of the agent taking the run. + pub agent_id: &'a str, + /// Opaque host-defined workload label. The crate never interprets it, so + /// routing rules stay entirely embedder-side. + pub workload: &'a str, + /// Explicit model override, when the caller or configuration pinned one. + pub pinned_model: Option<&'a str>, + /// Sampling temperature the caller asked for, when it asked. + pub temperature: Option, +} + +/// A [`ModelResolver`] that always resolves to one preconfigured model. +/// +/// The in-crate analogue of registering a single model on a harness: `resolve` +/// builds a fresh [`ModelRegistry`], registers the model under its name (which +/// makes it the registry default), and returns it. `profile` and +/// `context_window` take the `None` trait defaults, so it needs no host +/// configuration at all. +pub struct StaticModelResolver { + /// Name the model is registered under. + name: String, + /// The model every resolution returns. + model: Arc>, +} + +impl StaticModelResolver { + /// Creates a resolver that always returns `model`, registered as `name`. + pub fn new(name: impl Into, model: Arc>) -> Self { + Self { + name: name.into(), + model, + } + } + + /// The name the model is registered under. + pub fn name(&self) -> &str { + &self.name + } +} + +impl Clone for StaticModelResolver { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + model: Arc::clone(&self.model), + } + } +} + +impl std::fmt::Debug for StaticModelResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticModelResolver") + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +impl ModelResolver for StaticModelResolver { + fn resolve( + &self, + _state: &State, + _request: &ModelResolution<'_>, + ) -> Result> { + let mut registry = ModelRegistry::new(); + registry.register(self.name.clone(), Arc::clone(&self.model)); + Ok(registry) + } +} diff --git a/src/harness/host/progress.rs b/src/harness/host/progress.rs new file mode 100644 index 0000000..b110c42 --- /dev/null +++ b/src/harness/host/progress.rs @@ -0,0 +1,68 @@ +//! Back-pressured delivery of run events to an out-of-process consumer. +//! +//! See [`ProgressSink`] for the trait contract and [`NoopProgressSink`] for the +//! inert default. + +use async_trait::async_trait; + +use crate::error::Result; +use crate::harness::events::EventRecord; + +/// Asynchronous, back-pressured delivery of run events to an out-of-process +/// consumer. +/// +/// Complements [`EventListener`][crate::harness::events::EventListener], which +/// is synchronous and must not block the emitting step. A `ProgressSink` may +/// await — it exists for consumers behind a bounded channel, a socket, or an +/// IPC boundary, where dropping events silently is not acceptable. It carries +/// the crate's own [`EventRecord`] vocabulary; projecting that into a +/// presentation model is the embedder's job and stays outside the crate. +/// +/// This is a *delivery* seam, not a transport. It has no receive side: an +/// interactive loop that reads input from a terminal or a chat platform is host +/// surface and does not belong behind this trait. +#[async_trait] +pub trait ProgressSink: Send + Sync { + /// Whether a consumer is attached. + /// + /// Callers may use this to skip assembling expensive payloads (full tool + /// output, model input/output) when nothing will read them. A sink that + /// returns `false` must still tolerate [`Self::deliver`]. + fn is_connected(&self, state: &State) -> bool { + let _ = state; + true + } + + /// Delivers one record in offset order. `record` is borrowed; clone it to + /// retain it past the call. An `Err` means the consumer is gone; the + /// runtime logs it and continues rather than failing the run. + async fn deliver(&self, state: &State, record: &EventRecord) -> Result<()>; +} + +/// A [`ProgressSink`] with no consumer behind it. +/// +/// [`is_connected`][ProgressSink::is_connected] deliberately overrides the +/// trait default and returns `false`, so callers take their "nobody is +/// watching" fast path and skip assembling payloads nothing will read. +/// [`deliver`][ProgressSink::deliver] still succeeds, per the contract that a +/// disconnected sink must tolerate delivery. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopProgressSink; + +impl NoopProgressSink { + /// Creates the sink. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ProgressSink for NoopProgressSink { + fn is_connected(&self, _state: &State) -> bool { + false + } + + async fn deliver(&self, _state: &State, _record: &EventRecord) -> Result<()> { + Ok(()) + } +} diff --git a/src/harness/host/security.rs b/src/harness/host/security.rs new file mode 100644 index 0000000..2d97e64 --- /dev/null +++ b/src/harness/host/security.rs @@ -0,0 +1,355 @@ +//! Host policy consulted before the runtime widens what an agent can reach. +//! +//! See [`SecurityGate`] for the trait contract and +//! [`RootContainedSecurityGate`] for the fail-closed default. + +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{Result, TinyAgentsError}; +use crate::harness::ids::{CallId, RunId, ThreadId}; + +/// Host policy consulted before the runtime widens what an agent can see or +/// touch. +/// +/// The crate defines no policy of its own here: it defines the questions and +/// fails closed on the answers. Complements +/// [`WorkspaceIsolation`][crate::harness::workspace::WorkspaceIsolation], +/// which prepares an environment; this decides what is permitted inside one. +/// +/// Only [`authorize_call`][Self::authorize_call] is `async`: it is the one +/// question a realistic host answers with I/O (a policy store lookup, or an +/// operator approval round-trip). Tool-set narrowing, path resolution, input +/// screening, and redaction are in-process inspections, and forcing them +/// `async` would push `async` up through synchronous session-construction and +/// artifact-persistence paths for no benefit. +#[async_trait] +pub trait SecurityGate: Send + Sync { + /// Narrows the tool set advertised to a model. + /// + /// This is *advertisement*, not enforcement: it decides what the model is + /// told exists. Enforcement of an individual call — which depends on the + /// arguments, not just the name — is + /// [`authorize_call`][Self::authorize_call], and a gate that narrows here + /// without also deciding there admits anything the model names from + /// memory. + /// + /// The default exposes everything. + fn filter_tools( + &self, + state: &State, + request: &ToolExposureRequest<'_>, + ) -> Result { + let _ = state; + Ok(ToolExposure { + visible: request.available.to_vec(), + withheld: Vec::new(), + boundary_note: None, + }) + } + + /// Decides whether one concrete tool call may proceed. + /// + /// Three-valued on purpose: a policy that can only allow or deny cannot + /// express "ask a human first", which then silently degrades into one of + /// the other two. The decision sees the arguments, because the same tool + /// name is routinely both safe and unsafe depending on them. + /// + /// The default allows every call. + async fn authorize_call( + &self, + state: &State, + request: &ToolCallRequest<'_>, + ) -> Result { + let _ = (state, request); + Ok(CallVerdict::Allow) + } + + /// Resolves a caller-supplied path to an absolute path the run may use, or + /// errors. Implementations must reject traversal out of `root` and must not + /// depend on the target existing. + fn resolve_path(&self, state: &State, request: &PathRequest<'_>) -> Result; + + /// Screens untrusted inbound text before it becomes model input. + /// + /// The default admits everything. + fn screen_input( + &self, + state: &State, + request: &InputScreenRequest<'_>, + ) -> Result { + let _ = (state, request); + Ok(InputVerdict::Admit) + } + + /// Rewrites text the runtime is about to persist, render, or feed back to a + /// model — masking secrets, or fencing untrusted content so a model treats + /// it as data rather than instruction. + /// + /// Separate from [`screen_input`][Self::screen_input] because the answer is + /// modified text rather than a verdict, and because it runs in both + /// directions: inbound text on its way into a prompt, and outbound tool + /// output on its way into storage or a preview. + /// + /// The default returns the text unchanged. + fn redact(&self, state: &State, request: &RedactionRequest<'_>) -> Result { + let _ = state; + Ok(Redaction::unchanged(request.text)) + } +} + +/// The tool set a run could advertise, and how it was entered. +#[derive(Clone, Debug)] +pub struct ToolExposureRequest<'a> { + /// The run whose tool set is being narrowed. + pub run_id: &'a RunId, + /// Host-defined identity of the agent taking the run. + pub agent_id: &'a str, + /// Host-defined label for how this run was entered. + pub entrypoint: &'a str, + /// Every tool name registered on the run, before narrowing. + pub available: &'a [String], +} + +/// The narrowed tool set, plus optional host-authored explanation. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ToolExposure { + /// Names the model is told about. + pub visible: Vec, + /// Names deliberately withheld, for logging and boundary rendering. + #[serde(default)] + pub withheld: Vec, + /// Optional host-authored text describing the restriction, for the caller + /// to render into the prompt. The crate never generates this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub boundary_note: Option, +} + +/// One concrete tool call awaiting authorization. +#[derive(Clone, Debug)] +pub struct ToolCallRequest<'a> { + /// The run issuing the call. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Provider-assigned identity of this call. + pub call_id: &'a CallId, + /// Name of the tool the model asked for. + pub tool_name: &'a str, + /// Arguments the model supplied. Policy decisions routinely depend on + /// these, not only on `tool_name`. + pub arguments: &'a Value, + /// Host-defined label for how this run was entered. + pub entrypoint: &'a str, +} + +/// The decision on one tool call. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "verdict")] +pub enum CallVerdict { + /// Execute the call. + Allow, + /// Refuse the call outright. `code` is a stable host-defined discriminant; + /// `message` is host-authored text the runtime may hand back to the model + /// in place of a result. + Deny { + /// Stable host-defined discriminant. + code: String, + /// Host-authored explanation. + message: String, + }, + /// Hold the call pending an out-of-band decision. The runtime does not + /// define how approval is obtained; it only distinguishes "not now" from + /// "never" so a held call is not reported to the model as a refusal. + RequireApproval { + /// Stable host-defined discriminant. + code: String, + /// Host-authored explanation. + message: String, + }, +} + +/// Whether a path is being resolved for reading or for writing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathIntent { + /// The caller intends to read the resolved path. + Read, + /// The caller intends to create or modify the resolved path. + Write, +} + +/// A caller-supplied path awaiting containment checks. +#[derive(Clone, Debug)] +pub struct PathRequest<'a> { + /// The run requesting the path. + pub run_id: &'a RunId, + /// The path as supplied, relative or absolute. + pub path: &'a Path, + /// The root the resolved path must stay inside. + pub root: &'a Path, + /// What the caller intends to do with the result. + pub intent: PathIntent, +} + +/// Untrusted inbound text awaiting screening. +#[derive(Clone, Debug)] +pub struct InputScreenRequest<'a> { + /// The run the text would enter. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Host-defined label for where the text came from. + pub source: &'a str, + /// The text itself. + pub text: &'a str, +} + +/// The decision on one piece of inbound text. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "verdict")] +pub enum InputVerdict { + /// Let the text through unchanged. + Admit, + /// Refuse the input. `code` is a stable host-defined discriminant; + /// `message` is host-authored user-facing text. + Refuse { + /// Stable host-defined discriminant. + code: String, + /// Host-authored user-facing text. + message: String, + }, +} + +/// Which way text is flowing through [`SecurityGate::redact`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RedactionDirection { + /// Text on its way into a model request. + Inbound, + /// Text on its way out of the runtime — persisted, previewed, or logged. + Outbound, +} + +/// Text awaiting redaction, with the direction it is travelling. +#[derive(Clone, Debug)] +pub struct RedactionRequest<'a> { + /// The run the text belongs to. + pub run_id: &'a RunId, + /// Thread the run belongs to, when it belongs to one. + pub thread_id: Option<&'a ThreadId>, + /// Which way the text is flowing. + pub direction: RedactionDirection, + /// Host-defined label for what produced the text. + pub source: &'a str, + /// The text itself. + pub text: &'a str, +} + +/// The result of a redaction pass. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Redaction { + /// The text to use from here on. + pub value: String, + /// `true` when `value` differs from the input, so callers can stamp a + /// "modified" marker without diffing. + pub changed: bool, + /// Optional host-authored note describing what was changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl Redaction { + /// Returns `text` untouched. + pub fn unchanged(text: &str) -> Self { + Self { + value: text.to_string(), + changed: false, + note: None, + } + } + + /// Returns `value` as a modification of the original text. + pub fn changed(value: impl Into) -> Self { + Self { + value: value.into(), + changed: true, + note: None, + } + } + + /// Attaches a host-authored note describing the change. + pub fn with_note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } +} + +/// A [`SecurityGate`] whose only real decision is filesystem containment. +/// +/// [`resolve_path`][SecurityGate::resolve_path] normalises the request +/// lexically — dropping `.`, popping on `..`, rejecting embedded roots — and +/// errors with [`TinyAgentsError::Validation`] if the result would escape +/// `root`. The check never touches the filesystem, so a path that does not +/// exist yet resolves exactly like one that does; it is also therefore not a +/// defence against symlinks, which a host that cares about them must layer on. +/// +/// Every other method takes the permissive trait default: this gate fails +/// closed on the one question it actually answers and declines to invent policy +/// for the rest. +#[derive(Clone, Copy, Debug, Default)] +pub struct RootContainedSecurityGate; + +impl RootContainedSecurityGate { + /// Creates the gate. + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl SecurityGate for RootContainedSecurityGate { + fn resolve_path(&self, _state: &State, request: &PathRequest<'_>) -> Result { + let escape = || { + TinyAgentsError::Validation(format!( + "path {} escapes root {}", + request.path.display(), + request.root.display() + )) + }; + + // An absolute request is only meaningful if it is already inside the + // root; strip the root off and treat the remainder as relative so the + // component walk below is the single containment check. + let relative = if request.path.is_absolute() { + request + .path + .strip_prefix(request.root) + .map_err(|_| escape())? + } else { + request.path + }; + + let mut resolved = request.root.to_path_buf(); + for component in relative.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => resolved.push(part), + Component::ParentDir => { + if !resolved.pop() || !resolved.starts_with(request.root) { + return Err(escape()); + } + } + Component::RootDir | Component::Prefix(_) => return Err(escape()), + } + } + + if !resolved.starts_with(request.root) { + return Err(escape()); + } + Ok(resolved) + } +} diff --git a/src/harness/host/test.rs b/src/harness/host/test.rs new file mode 100644 index 0000000..cba6d66 --- /dev/null +++ b/src/harness/host/test.rs @@ -0,0 +1,945 @@ +//! Unit tests for the host capability seams and their default implementations. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use super::*; +use crate::harness::events::{AgentEvent, EventRecord}; +use crate::harness::ids::{CallId, EventId, RunId, ThreadId}; +use crate::harness::model::{ChatModel, ModelResponse}; +use crate::harness::testkit::ScriptedModel; +use crate::harness::usage::Usage; +use crate::harness::workspace::WorkspaceDescriptor; + +/// The unit application state every default implementation is exercised with. +const STATE: &() = &(); + +fn run_id() -> RunId { + RunId::new("run-1") +} + +fn thread_id(value: &str) -> ThreadId { + ThreadId::new(value) +} + +// --------------------------------------------------------------------------- +// MemoryProvider — InMemoryMemoryProvider +// --------------------------------------------------------------------------- + +async fn seeded_memory() -> InMemoryMemoryProvider { + let memory = InMemoryMemoryProvider::new(); + memory + .write( + STATE, + MemoryWrite::new("preferences", "tone", "the user prefers terse answers") + .with_category("style") + .with_thread_id("t1"), + ) + .await + .unwrap(); + memory + .write( + STATE, + MemoryWrite::new("preferences", "format", "answers should use bullet lists") + .with_category("style") + .with_thread_id("t2"), + ) + .await + .unwrap(); + memory + .write( + STATE, + MemoryWrite::new("facts", "city", "the user lives in Lisbon").with_category("profile"), + ) + .await + .unwrap(); + memory +} + +#[tokio::test] +async fn in_memory_provider_recalls_by_token_overlap_scored_and_sorted() { + let memory = seeded_memory().await; + + let hits = memory + .recall(STATE, &MemoryQuery::new("terse answers")) + .await + .unwrap(); + + assert_eq!(hits.len(), 2, "both preference rows contain 'answers'"); + assert_eq!(hits[0].key, "tone"); + assert_eq!(hits[0].score, Some(1.0), "matched both query tokens"); + assert_eq!(hits[1].key, "format"); + assert_eq!(hits[1].score, Some(0.5), "matched one of two query tokens"); +} + +#[tokio::test] +async fn in_memory_provider_returns_empty_for_empty_query_without_erroring() { + let memory = seeded_memory().await; + + // The contract explicitly promises `Ok(vec![])` rather than an error, and + // the zero-token case must not reach the matched/total division. + assert!( + memory + .recall(STATE, &MemoryQuery::new("")) + .await + .unwrap() + .is_empty() + ); + assert!( + memory + .recall(STATE, &MemoryQuery::new(" \t \n ")) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn in_memory_provider_returns_empty_for_non_matching_query() { + let memory = seeded_memory().await; + + let hits = memory + .recall(STATE, &MemoryQuery::new("quantum chromodynamics")) + .await + .unwrap(); + + assert!(hits.is_empty()); +} + +#[tokio::test] +async fn in_memory_provider_honours_namespace_limit_and_min_score() { + let memory = seeded_memory().await; + + let namespaced = memory + .recall(STATE, &MemoryQuery::new("user").with_namespace("facts")) + .await + .unwrap(); + assert_eq!(namespaced.len(), 1); + assert_eq!(namespaced[0].key, "city"); + + let limited = memory + .recall(STATE, &MemoryQuery::new("terse answers").with_limit(1)) + .await + .unwrap(); + assert_eq!(limited.len(), 1); + assert_eq!(limited[0].key, "tone"); + + let floored = memory + .recall( + STATE, + &MemoryQuery::new("terse answers").with_min_score(0.9), + ) + .await + .unwrap(); + assert_eq!(floored.len(), 1); + assert_eq!(floored[0].key, "tone"); +} + +#[tokio::test] +async fn in_memory_provider_scopes_recall_to_a_thread_unless_cross_thread() { + let memory = seeded_memory().await; + let t1 = thread_id("t1"); + + let same_thread = memory + .recall(STATE, &MemoryQuery::new("answers").with_thread(&t1, false)) + .await + .unwrap(); + assert_eq!(same_thread.len(), 1); + assert_eq!(same_thread[0].key, "tone"); + + let cross_thread = memory + .recall(STATE, &MemoryQuery::new("answers").with_thread(&t1, true)) + .await + .unwrap(); + assert_eq!(cross_thread.len(), 2); +} + +#[tokio::test] +async fn in_memory_provider_lists_by_namespace_and_category_without_scores() { + let memory = seeded_memory().await; + + let style = memory + .list( + STATE, + &MemoryFilter::new() + .with_namespace("preferences") + .with_category("style"), + ) + .await + .unwrap(); + + assert_eq!(style.len(), 2); + assert_eq!(style[0].key, "format", "list sorts by key ascending"); + assert_eq!(style[1].key, "tone"); + assert!(style.iter().all(|record| record.score.is_none())); + + let missing = memory + .list(STATE, &MemoryFilter::new().with_category("nonexistent")) + .await + .unwrap(); + assert!(missing.is_empty()); +} + +#[tokio::test] +async fn in_memory_provider_write_upserts_on_namespace_and_key() { + let memory = InMemoryMemoryProvider::new(); + memory + .write(STATE, MemoryWrite::new("facts", "city", "Lisbon")) + .await + .unwrap(); + memory + .write(STATE, MemoryWrite::new("facts", "city", "Porto")) + .await + .unwrap(); + + let all = memory.list(STATE, &MemoryFilter::new()).await.unwrap(); + assert_eq!(all.len(), 1, "same (namespace, key) overwrites"); + assert_eq!(all[0].content, "Porto"); + assert_eq!(all[0].id, "facts/city"); + assert_eq!(all[0].namespace.as_deref(), Some("facts")); + assert_eq!(memory.len().unwrap(), 1); +} + +#[tokio::test] +async fn in_memory_provider_namespace_digests_take_the_empty_trait_default() { + let memory = seeded_memory().await; + + let digests = memory + .namespace_digests( + STATE, + DigestCaps { + per_namespace_max_chars: 512, + total_max_chars: 2048, + }, + ) + .await + .unwrap(); + + assert!(digests.is_empty(), "no rollup layer, so opt out not error"); +} + +#[test] +fn memory_record_round_trips_through_serde_omitting_absent_fields() { + let record = MemoryRecord { + id: "facts/city".into(), + key: "city".into(), + content: "Lisbon".into(), + namespace: Some("facts".into()), + category: None, + thread_id: None, + score: None, + recorded_at: None, + attributes: Value::Null, + }; + + let encoded = serde_json::to_value(&record).unwrap(); + assert!(encoded.get("category").is_none()); + assert!(encoded.get("score").is_none()); + + let decoded: MemoryRecord = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, record); +} + +// --------------------------------------------------------------------------- +// ContextComposer — PassthroughContextComposer +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn passthrough_composer_contributes_no_prompt_and_no_blocks() { + let composer = PassthroughContextComposer::new(); + let run = run_id(); + let workspace = WorkspaceDescriptor::new(PathBuf::from("/tmp/workspace")); + let visible = vec!["read_file".to_string()]; + + let prompt = ContextComposer::compose_system_prompt( + &composer, + STATE, + &SystemPromptRequest { + run_id: &run, + thread_id: None, + agent_id: "agent", + model_id: "model", + tools: &[], + visible_tool_names: &visible, + tool_call_instructions: "", + workspace: Some(&workspace), + }, + ) + .unwrap(); + assert!(prompt.is_empty()); + + let prepared = composer + .prepare_turn( + STATE, + &TurnPreparationRequest { + run_id: &run, + thread_id: None, + agent_id: "agent", + input: "hello", + turn_index: 0, + first_turn: true, + resumed: false, + }, + ) + .await + .unwrap(); + + assert_eq!(prepared, TurnPreparation::default()); + assert!(prepared.blocks.is_empty()); + assert!(prepared.extras.is_null()); +} + +#[test] +fn context_block_defaults_to_the_turn_prefix_placement() { + let block = ContextBlock::new("recall", "body text"); + assert_eq!(block.placement, ContextPlacement::TurnPrefix); + assert_eq!(block.priority, 0); + + let system = block + .clone() + .with_placement(ContextPlacement::SystemPrefix) + .with_priority(10); + assert_eq!(system.placement, ContextPlacement::SystemPrefix); + assert_eq!(system.priority, 10); +} + +#[test] +fn turn_preparation_round_trips_and_omits_null_extras() { + let prepared = TurnPreparation::new(vec![ContextBlock::new("goal", "finish the migration")]) + .with_extras(json!({ "host": "opaque" })); + + let encoded = serde_json::to_value(&prepared).unwrap(); + assert_eq!(encoded["extras"]["host"], "opaque"); + + let bare = serde_json::to_value(TurnPreparation::default()).unwrap(); + assert!(bare.get("extras").is_none()); + + let decoded: TurnPreparation = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, prepared); +} + +// --------------------------------------------------------------------------- +// SecurityGate — RootContainedSecurityGate +// --------------------------------------------------------------------------- + +fn resolve(path: &str, root: &str, intent: PathIntent) -> crate::error::Result { + let gate = RootContainedSecurityGate::new(); + let run = run_id(); + SecurityGate::resolve_path( + &gate, + STATE, + &PathRequest { + run_id: &run, + path: Path::new(path), + root: Path::new(root), + intent, + }, + ) +} + +#[test] +fn root_contained_gate_resolves_relative_paths_under_the_root() { + assert_eq!( + resolve("notes/today.md", "/work", PathIntent::Read).unwrap(), + PathBuf::from("/work/notes/today.md") + ); + assert_eq!( + resolve("./notes/./today.md", "/work", PathIntent::Write).unwrap(), + PathBuf::from("/work/notes/today.md") + ); + assert_eq!( + resolve("notes/../today.md", "/work", PathIntent::Write).unwrap(), + PathBuf::from("/work/today.md") + ); + assert_eq!( + resolve("/work/notes/today.md", "/work", PathIntent::Read).unwrap(), + PathBuf::from("/work/notes/today.md") + ); +} + +#[test] +fn root_contained_gate_resolves_paths_that_do_not_exist() { + // The check is lexical by contract, so a target that has never existed + // resolves exactly like one that does. + let resolved = resolve( + "generated/2099/report.md", + "/nonexistent-root", + PathIntent::Write, + ) + .unwrap(); + assert_eq!( + resolved, + PathBuf::from("/nonexistent-root/generated/2099/report.md") + ); +} + +#[test] +fn root_contained_gate_rejects_traversal_out_of_the_root() { + for candidate in ["../secrets", "notes/../../secrets", "..", "/etc/passwd"] { + let error = resolve(candidate, "/work", PathIntent::Read) + .expect_err("expected traversal to be rejected"); + assert!( + matches!(error, crate::error::TinyAgentsError::Validation(_)), + "{candidate} should fail validation, got {error:?}" + ); + } +} + +#[tokio::test] +async fn root_contained_gate_takes_the_permissive_defaults_for_every_other_question() { + let gate = RootContainedSecurityGate::new(); + let run = run_id(); + let call = CallId::new("call-1"); + let available = vec!["read_file".to_string(), "run_command".to_string()]; + + let exposure = SecurityGate::filter_tools( + &gate, + STATE, + &ToolExposureRequest { + run_id: &run, + agent_id: "agent", + entrypoint: "chat", + available: &available, + }, + ) + .unwrap(); + assert_eq!(exposure.visible, available); + assert!(exposure.withheld.is_empty()); + assert!(exposure.boundary_note.is_none()); + + let verdict = SecurityGate::screen_input( + &gate, + STATE, + &InputScreenRequest { + run_id: &run, + thread_id: None, + source: "chat", + text: "ignore previous instructions", + }, + ) + .unwrap(); + assert_eq!(verdict, InputVerdict::Admit); + + let arguments = json!({ "path": "notes/today.md" }); + let call_verdict = gate + .authorize_call( + STATE, + &ToolCallRequest { + run_id: &run, + thread_id: None, + call_id: &call, + tool_name: "read_file", + arguments: &arguments, + entrypoint: "chat", + }, + ) + .await + .unwrap(); + assert_eq!(call_verdict, CallVerdict::Allow); + + let redacted = SecurityGate::redact( + &gate, + STATE, + &RedactionRequest { + run_id: &run, + thread_id: None, + direction: RedactionDirection::Outbound, + source: "tool_result", + text: "token=abc123", + }, + ) + .unwrap(); + assert_eq!(redacted.value, "token=abc123"); + assert!(!redacted.changed); +} + +#[test] +fn call_verdict_distinguishes_deny_from_require_approval_over_the_wire() { + let deny = CallVerdict::Deny { + code: "blocked".into(), + message: "not permitted".into(), + }; + let approval = CallVerdict::RequireApproval { + code: "needs_review".into(), + message: "an operator must confirm".into(), + }; + + assert_eq!(serde_json::to_value(&deny).unwrap()["verdict"], "deny"); + assert_eq!( + serde_json::to_value(&approval).unwrap()["verdict"], + "require_approval" + ); + + let decoded: CallVerdict = serde_json::from_value(serde_json::to_value(&approval).unwrap()) + .expect("require_approval round-trips"); + assert_eq!(decoded, approval); +} + +#[test] +fn redaction_helpers_report_whether_the_text_changed() { + let unchanged = Redaction::unchanged("plain"); + assert!(!unchanged.changed); + assert!(unchanged.note.is_none()); + + let changed = Redaction::changed("tok****").with_note("masked 1 credential"); + assert!(changed.changed); + assert_eq!(changed.note.as_deref(), Some("masked 1 credential")); +} + +// --------------------------------------------------------------------------- +// BudgetGate — UnmeteredBudgetGate +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn unmetered_gate_admits_records_and_never_stops_a_run() { + let gate = UnmeteredBudgetGate::new(); + let run = run_id(); + let call = CallId::new("call-1"); + + let lease = gate + .acquire( + STATE, + &AdmissionRequest { + run_id: &run, + agent_id: "agent", + workload: "chat", + interactive: true, + }, + ) + .await + .unwrap(); + assert!(lease.is_some(), "unmetered admission always grants a lease"); + + let usage = Usage { + input_tokens: 100, + output_tokens: 20, + total_tokens: 120, + ..Default::default() + }; + assert_eq!( + BudgetGate::estimate_cost(&gate, STATE, "model", &usage), + crate::harness::cost::CostTotals::default() + ); + + gate.record_usage( + STATE, + &UsageEntry { + run_id: &run, + call_id: &call, + model_id: "model", + usage, + cost: crate::harness::cost::CostTotals::default(), + }, + ) + .await + .unwrap(); + + let verdict = gate + .account_turn( + STATE, + &TurnCharge { + run_id: &run, + thread_id: None, + usage, + cost: crate::harness::cost::CostTotals::default(), + elapsed_secs: 3, + }, + ) + .await + .unwrap(); + assert_eq!(verdict, BudgetVerdict::Continue); +} + +#[test] +fn budget_lease_carries_an_opaque_host_guard_back_out() { + let lease = BudgetLease::new(String::from("permit")); + assert_eq!(format!("{lease:?}"), "BudgetLease(..)"); + + let guard = lease.into_inner(); + assert_eq!( + guard.downcast_ref::().map(String::as_str), + Some("permit") + ); + + // The unmetered lease holds nothing at all and is still droppable. + drop(BudgetLease::unmetered()); +} + +#[test] +fn budget_verdict_stop_carries_a_host_authored_reason() { + let stop = BudgetVerdict::Stop { + reason: "goal budget exhausted".into(), + }; + let encoded = serde_json::to_value(&stop).unwrap(); + assert_eq!(encoded["verdict"], "stop"); + assert_eq!(encoded["reason"], "goal budget exhausted"); + + let decoded: BudgetVerdict = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, stop); +} + +// --------------------------------------------------------------------------- +// DefinitionRegistry — InMemoryDefinitionRegistry +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn in_memory_definition_registry_starts_empty() { + let registry = InMemoryDefinitionRegistry::new(); + + assert!( + DefinitionRegistry::list(®istry, STATE) + .await + .unwrap() + .is_empty() + ); + assert!( + DefinitionRegistry::get(®istry, STATE, "anything") + .await + .unwrap() + .is_none() + ); + assert!( + DefinitionRegistry::default_id(®istry, STATE) + .await + .unwrap() + .is_none(), + "the crate ships no default agent identity" + ); + assert!(registry.is_empty().unwrap()); +} + +#[tokio::test] +async fn in_memory_definition_registry_lists_in_insertion_order() { + let registry = InMemoryDefinitionRegistry::new() + .with_definition(AgentDefinition::new("beta")) + .with_definition(AgentDefinition::new("alpha")) + .with_default_id("beta"); + + let listed = DefinitionRegistry::list(®istry, STATE).await.unwrap(); + let ids: Vec<&str> = listed.iter().map(|d| d.id.as_str()).collect(); + assert_eq!(ids, vec!["beta", "alpha"], "insertion order, not sorted"); + + assert_eq!( + DefinitionRegistry::default_id(®istry, STATE) + .await + .unwrap() + .as_deref(), + Some("beta") + ); +} + +#[tokio::test] +async fn in_memory_definition_registry_replaces_in_place_without_reordering() { + let registry = InMemoryDefinitionRegistry::new() + .with_definition(AgentDefinition::new("first")) + .with_definition(AgentDefinition::new("second")); + + registry + .insert(AgentDefinition::new("first").with_description("updated")) + .unwrap(); + + let listed = DefinitionRegistry::list(®istry, STATE).await.unwrap(); + assert_eq!(listed.len(), 2, "replace, not append"); + assert_eq!(listed[0].id, "first"); + assert_eq!(listed[0].description.as_deref(), Some("updated")); + assert_eq!(listed[1].id, "second"); +} + +#[test] +fn agent_definition_keeps_host_specific_data_in_opaque_extras() { + let definition = AgentDefinition::new("assistant") + .with_description("general purpose") + .with_system_prompt("be helpful") + .with_model("model-a") + .with_tools(["read_file", "write_file"]) + .with_extras(json!({ "host_only": { "compaction": "aggressive" } })); + + let encoded = serde_json::to_value(&definition).unwrap(); + assert_eq!(encoded["extras"]["host_only"]["compaction"], "aggressive"); + + let decoded: AgentDefinition = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, definition); + + // A bare definition serializes to just its id: every other field is + // skipped when absent, so the wire shape stays minimal. + let bare = serde_json::to_value(AgentDefinition::new("bare")).unwrap(); + assert_eq!(bare, json!({ "id": "bare" })); +} + +// --------------------------------------------------------------------------- +// ExperienceStore — NoopExperienceStore +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn noop_experience_store_retrieves_nothing_and_retains_nothing() { + let store = NoopExperienceStore::new(); + let tools = vec!["read_file".to_string()]; + + let hits = store + .retrieve( + STATE, + &ExperienceQuery { + text: "migrate the database", + agent_id: Some("agent"), + entrypoint: Some("chat"), + partition: Some("partition-a"), + tool_names: &tools, + max_hits: 5, + }, + ) + .await + .unwrap(); + assert!(hits.is_empty()); + + store + .record( + STATE, + vec![ExperienceEntry { + id: "outcome-1".into(), + agent_id: Some("agent".into()), + partition: None, + payload: json!({ "opaque": true }), + }], + ) + .await + .unwrap(); +} + +#[test] +fn experience_hit_round_trips_with_its_match_reasons() { + let hit = ExperienceHit { + id: "outcome-1".into(), + body: "previously solved by reading the manifest first".into(), + score: Some(0.8), + match_reasons: vec!["same_tool".into()], + }; + + let decoded: ExperienceHit = + serde_json::from_value(serde_json::to_value(&hit).unwrap()).unwrap(); + assert_eq!(decoded, hit); + + let evidence_free = ExperienceHit { + match_reasons: Vec::new(), + ..hit + }; + assert!( + evidence_free.match_reasons.is_empty(), + "callers may drop hits with no stated reason" + ); +} + +// --------------------------------------------------------------------------- +// LearningSink — NoopLearningSink +// --------------------------------------------------------------------------- + +fn sample_turn_record() -> TurnRecord { + TurnRecord { + run_id: run_id(), + thread_id: Some(thread_id("t1")), + agent_id: Some("agent".into()), + entrypoint: Some("chat".into()), + input: "hello".into(), + output: "hi".into(), + tool_calls: vec![ToolOutcomeRecord { + name: "read_file".into(), + arguments: json!({ "path": "notes.md" }), + succeeded: true, + summary: "read 1 file".into(), + elapsed_ms: 12, + }], + model_calls: 2, + elapsed_ms: 350, + } +} + +#[tokio::test] +async fn noop_learning_sink_accepts_turns_and_transcripts_without_effect() { + let sink = NoopLearningSink::new(); + let record = sample_turn_record(); + let run = run_id(); + let thread = thread_id("t1"); + + sink.on_turn_completed(STATE, &record).await.unwrap(); + sink.on_transcript_committed( + STATE, + &TranscriptCommit { + run_id: &run, + thread_id: Some(&thread), + path: Path::new("/workspace/session_raw/t1.jsonl"), + appended_messages: 4, + }, + ) + .await + .unwrap(); +} + +#[test] +fn turn_record_round_trips_through_serde() { + let record = sample_turn_record(); + let decoded: TurnRecord = + serde_json::from_value(serde_json::to_value(&record).unwrap()).unwrap(); + assert_eq!(decoded, record); +} + +// --------------------------------------------------------------------------- +// ProgressSink — NoopProgressSink +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn noop_progress_sink_reports_disconnected_but_still_accepts_delivery() { + let sink = NoopProgressSink::new(); + assert!( + !ProgressSink::is_connected(&sink, STATE), + "callers should take the skip-expensive-payload path" + ); + + let record = EventRecord { + id: EventId::new("event-1"), + offset: 0, + event: AgentEvent::RunStarted { + run_id: run_id(), + thread_id: None, + }, + }; + sink.deliver(STATE, &record).await.unwrap(); +} + +// --------------------------------------------------------------------------- +// ToolOutcomeClassifier — NoopToolOutcomeClassifier +// --------------------------------------------------------------------------- + +#[test] +fn noop_classifier_leaves_every_failure_unclassified() { + let classifier = NoopToolOutcomeClassifier::new(); + let call = CallId::new("call-1"); + + let classified = ToolOutcomeClassifier::classify( + &classifier, + STATE, + &ToolFailureContext { + call_id: &call, + tool_name: "http_request", + error: "connection reset by peer", + timed_out: false, + }, + ); + + assert!(classified.is_none()); +} + +#[test] +fn retry_disposition_defaults_to_unknown_and_unknown_is_not_retryable() { + assert_eq!(RetryDisposition::default(), RetryDisposition::Unknown); + assert!(!RetryDisposition::Unknown.is_retryable()); + assert!(!RetryDisposition::Never.is_retryable()); + assert!(RetryDisposition::Immediate.is_retryable()); + assert!(RetryDisposition::Backoff.is_retryable()); +} + +#[test] +fn tool_failure_round_trips_and_defaults_its_retry_disposition() { + let failure = ToolFailure { + class: "host_timeout".into(), + category: "recoverable".into(), + cause: "the request exceeded its deadline".into(), + next_action: "retry with a narrower query".into(), + retry: RetryDisposition::Backoff, + }; + + let encoded = serde_json::to_value(&failure).unwrap(); + assert_eq!(encoded["retry"], "backoff"); + let decoded: ToolFailure = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, failure); + + let legacy: ToolFailure = serde_json::from_value(json!({ + "class": "x", + "category": "y", + "cause": "c", + "next_action": "n" + })) + .unwrap(); + assert_eq!(legacy.retry, RetryDisposition::Unknown); +} + +// --------------------------------------------------------------------------- +// ModelResolver — StaticModelResolver +// --------------------------------------------------------------------------- + +#[test] +fn static_resolver_builds_a_registry_whose_default_is_the_configured_model() { + let model: Arc> = + Arc::new(ScriptedModel::new(vec![ModelResponse::assistant("hello")])); + let resolver = StaticModelResolver::new("scripted", model); + let run = run_id(); + + let registry = resolver + .resolve( + STATE, + &ModelResolution { + run_id: &run, + agent_id: "agent", + workload: "chat", + pinned_model: None, + temperature: Some(0.2), + }, + ) + .unwrap(); + + assert!(registry.get("scripted").is_some()); + assert!( + registry.default_model().is_some(), + "the first registered model becomes the registry default" + ); + assert_eq!(resolver.name(), "scripted"); +} + +#[test] +fn static_resolver_reports_unknown_capabilities_rather_than_guessing() { + let model: Arc> = Arc::new(ScriptedModel::new(Vec::new())); + let resolver = StaticModelResolver::new("scripted", model); + + assert!(resolver.profile(STATE, "scripted").unwrap().is_none()); + assert!( + resolver + .context_window(STATE, "scripted") + .unwrap() + .is_none() + ); +} + +// --------------------------------------------------------------------------- +// Object safety — every seam must be usable behind `Arc` +// --------------------------------------------------------------------------- + +#[test] +fn every_seam_is_usable_as_a_trait_object() { + let _memory: Arc> = Arc::new(InMemoryMemoryProvider::new()); + let _context: Arc> = Arc::new(PassthroughContextComposer::new()); + let _security: Arc> = Arc::new(RootContainedSecurityGate::new()); + let _budget: Arc> = Arc::new(UnmeteredBudgetGate::new()); + let _definitions: Arc> = Arc::new(InMemoryDefinitionRegistry::new()); + let _experience: Arc> = Arc::new(NoopExperienceStore::new()); + let _learning: Arc> = Arc::new(NoopLearningSink::new()); + let _progress: Arc> = Arc::new(NoopProgressSink::new()); + let _classifier: Arc> = Arc::new(NoopToolOutcomeClassifier); + + let model: Arc> = Arc::new(ScriptedModel::new(Vec::new())); + let _resolver: Arc> = Arc::new(StaticModelResolver::new("m", model)); +} + +#[tokio::test] +async fn trait_object_futures_are_send_so_seams_can_be_spawned() { + let memory: Arc + 'static> = Arc::new(InMemoryMemoryProvider::new()); + + let handle = tokio::spawn(async move { + memory + .recall(&(), &MemoryQuery::new("anything")) + .await + .unwrap() + .len() + }); + + assert_eq!(handle.await.unwrap(), 0); +} diff --git a/src/harness/host/tool_outcome.rs b/src/harness/host/tool_outcome.rs new file mode 100644 index 0000000..c59ccd1 --- /dev/null +++ b/src/harness/host/tool_outcome.rs @@ -0,0 +1,108 @@ +//! Host classification of raw tool failures. +//! +//! See [`ToolOutcomeClassifier`] for the trait contract and +//! [`NoopToolOutcomeClassifier`] for the inert default. + +use serde::{Deserialize, Serialize}; + +use crate::harness::ids::CallId; + +/// Turns a raw tool failure into a structured, host-defined classification. +/// +/// Synchronous and pure by contract — implementations inspect text and return, +/// with no I/O — mirroring +/// [`EventListener`][crate::harness::events::EventListener], the crate's other +/// non-async extension point. Every string in [`ToolFailure`] is authored by +/// the embedder; the crate defines no failure taxonomy and no user-facing copy. +pub trait ToolOutcomeClassifier: Send + Sync { + /// Classifies a failed call, or returns `None` to leave it unclassified. + fn classify(&self, state: &State, failure: &ToolFailureContext<'_>) -> Option; +} + +/// A failed tool call awaiting classification. +#[derive(Clone, Debug)] +pub struct ToolFailureContext<'a> { + /// Provider-assigned identity of the call that failed. + pub call_id: &'a CallId, + /// Name of the tool that failed. + pub tool_name: &'a str, + /// The failure text, with any separate error field and result body already + /// combined so a classifier need not guess where the signal is. + pub error: &'a str, + /// `true` when the runtime aborted the call on its own deadline, which a + /// classifier cannot infer reliably from text alone. + pub timed_out: bool, +} + +/// A host-defined classification of one failure. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolFailure { + /// Stable host-defined discriminant. + /// + /// Display and logging only. Runtime code must never branch on this value: + /// the taxonomy behind it is the embedder's, and matching on its strings + /// would pull host vocabulary into crate control flow. Branch on + /// [`retry`][Self::retry] instead. + pub class: String, + /// Coarser host-defined grouping the class belongs to. Display only, for + /// the same reason as [`class`][Self::class]. + pub category: String, + /// Host-authored description of what went wrong. + pub cause: String, + /// Host-authored description of what to do next. + pub next_action: String, + /// How the runtime should treat a retry of this call. + #[serde(default)] + pub retry: RetryDisposition, +} + +/// How the runtime should treat a retry of a failed call. +/// +/// A crate-owned, product-neutral vocabulary so retry policy can live in crate +/// code without matching on host class names — and so "we do not know" stays +/// distinguishable from "yes, retry", which a plain `bool` collapses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryDisposition { + /// Nothing is known about whether a retry would help. Runtimes should treat + /// this conservatively rather than as an invitation to retry. + #[default] + Unknown, + /// A retry cannot succeed; the failure is deterministic. + Never, + /// A retry may succeed straight away. + Immediate, + /// A retry may succeed after a delay — the failure is transient (a + /// timeout, an unavailable dependency, a dropped connection). + Backoff, +} + +impl RetryDisposition { + /// Whether the runtime may retry the call at all. + /// + /// [`Unknown`][Self::Unknown] answers `false`: an unclassified failure is + /// not evidence that retrying is safe. + pub fn is_retryable(self) -> bool { + matches!(self, Self::Immediate | Self::Backoff) + } +} + +/// A [`ToolOutcomeClassifier`] that classifies nothing. +/// +/// `classify` returns `None` for every input, leaving failures unclassified. +/// Sync, so it needs no async machinery at all. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopToolOutcomeClassifier; + +impl NoopToolOutcomeClassifier { + /// Creates the classifier. + pub fn new() -> Self { + Self + } +} + +impl ToolOutcomeClassifier for NoopToolOutcomeClassifier { + fn classify(&self, _state: &State, _failure: &ToolFailureContext<'_>) -> Option { + None + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 217eeea..d6c32f8 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -18,6 +18,7 @@ pub mod context; pub mod cost; pub mod embeddings; pub mod events; +pub mod host; pub mod ids; pub mod limits; pub mod memory; diff --git a/src/harness/providers/openai/README.md b/src/harness/providers/openai/README.md index 408def0..58e7b66 100644 --- a/src/harness/providers/openai/README.md +++ b/src/harness/providers/openai/README.md @@ -37,12 +37,21 @@ of the rest of the harness. - `OpenAiModel::from_spec(spec, api_key)` / `from_spec_env(spec)` — build from a `providers::ProviderSpec` (base URL, default model, provider id already resolved). -- **Compatibility presets** — thin wrappers over `new` + `with_base_url` + - `with_model` for endpoints that speak the same Chat Completions wire format: +- **Compatibility presets** for hosted endpoints that speak the same Chat + Completions wire format: `compatible(base_url, model)` / `compatible_provider(..)` (arbitrary endpoint), `deepseek`, `anthropic` (compat endpoint, not the native - Anthropic API), `groq`, `xai`, `openrouter`, `together`, `mistral`, `ollama`. + Anthropic API), `groq`, `xai`, `openrouter`, `together`, and `mistral`. Override the preset's default model with `.with_model(..)`. +- **Local-runtime presets** — `ollama()`, fallible + `ollama_at(base_url, model)`, and fallible + `lm_studio(base_url, api_key, model)`. These normalize a server or + `/v1/models` URL to its `/v1` API base and use conservative local defaults: + no native or parallel tool calls, no streamed tool chunks, and no image + input. `ollama()` and `ollama_at()` send no authorization header; LM Studio + sends a bearer token only when its API key is non-empty. + `ProviderSpec::Ollama` uses the same defaults unless `requires_api_key` is + enabled, in which case it sends a bearer token. Accessors: `.model()`, `.provider()`, `.base_url()`. diff --git a/src/harness/providers/openai/test.rs b/src/harness/providers/openai/test.rs index e19d18e..f842485 100644 --- a/src/harness/providers/openai/test.rs +++ b/src/harness/providers/openai/test.rs @@ -639,29 +639,46 @@ fn compatible_presets_set_base_url_and_default_model() { #[test] fn local_runtime_presets_normalize_endpoint_and_model() { - let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b"); + let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b").unwrap(); assert_eq!(ollama.provider(), "ollama"); assert_eq!(ollama.base_url(), "http://127.0.0.1:11434/v1"); assert_eq!(ollama.model(), "qwen3:8b"); - let lm_studio = OpenAiModel::lm_studio("http://127.0.0.1:1234/v1/models", "", "local-model"); + let lm_studio = + OpenAiModel::lm_studio("http://127.0.0.1:1234/v1/models", "", "local-model").unwrap(); assert_eq!(lm_studio.provider(), "lm_studio"); assert_eq!(lm_studio.base_url(), "http://127.0.0.1:1234/v1"); assert_eq!(lm_studio.model(), "local-model"); assert_eq!( - OpenAiModel::ollama_at("http://models", "qwen3").base_url(), + OpenAiModel::ollama_at("http://models", "qwen3") + .unwrap() + .base_url(), "http://models/v1" ); assert_eq!( - OpenAiModel::ollama_at("http://v1", "qwen3").base_url(), + OpenAiModel::ollama_at("http://v1", "qwen3") + .unwrap() + .base_url(), "http://v1/v1" ); let overridden = OpenAiModel::ollama().with_model("qwen3:8b"); let profile = >::profile(&overridden).unwrap(); assert!(!profile.tool_calling); + assert!(!profile.parallel_tool_calls); + assert!(!profile.streaming_tool_chunks); assert!(!profile.modalities.image_in); + + assert!(OpenAiModel::ollama_at("http://[::1", "qwen3").is_err()); + assert!(OpenAiModel::ollama_at("ftp://host", "qwen3").is_err()); + + let caller_client = OpenAiModel::ollama().with_client(reqwest::Client::new()); + assert_eq!(caller_client.effective_request_timeout(None, false), None); + assert_eq!( + caller_client.effective_request_timeout(Some(25), false), + Some(std::time::Duration::from_millis(25)) + ); } #[test] @@ -679,6 +696,19 @@ fn provider_spec_builds_compatible_model() { .as_deref(), Some("ollama") ); + let profile = >::profile(&model).unwrap(); + assert!(!profile.tool_calling); + assert!(!profile.parallel_tool_calls); + assert!(!profile.streaming_tool_chunks); + assert!(!profile.modalities.image_in); + + let mut authenticated = ProviderSpec::for_kind(ProviderKind::Ollama); + authenticated.requires_api_key = true; + let authenticated = OpenAiModel::from_spec(authenticated, "proxy-secret").unwrap(); + assert_eq!( + authenticated.auth_config(), + ("proxy-secret", &AuthStyle::Bearer) + ); } #[test] diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index 1272ef3..282eb7f 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -39,6 +39,8 @@ pub enum AuthStyle { pub struct OpenAiModel { /// Shared HTTP client. client: reqwest::Client, + /// Whether the client was supplied by the caller and owns default deadlines. + caller_owned_client: bool, /// API credential; how it is sent is governed by [`Self::auth`]. api_key: String, /// How `api_key` is attached to each request (default [`AuthStyle::Bearer`]). @@ -317,6 +319,7 @@ impl OpenAiModel { .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS)) .build() .expect("default reqwest client builds"), + caller_owned_client: false, api_key: api_key.into(), auth: AuthStyle::Bearer, extra_headers: Vec::new(), @@ -393,6 +396,14 @@ impl OpenAiModel { self } + /// Reuses a caller-configured HTTP client, including its connection pool, + /// proxy settings, and request deadlines. + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self.caller_owned_client = true; + self + } + /// Overrides how the API credential is sent (default [`AuthStyle::Bearer`]). /// /// Use this for OpenAI-compatible endpoints that authenticate with @@ -545,12 +556,19 @@ impl OpenAiModel { /// Overrides the default model id. pub fn with_model(mut self, model: impl Into) -> Self { self.model = model.into(); - let local_capabilities = self - .local_capabilities_locked - .then_some((self.profile.tool_calling, self.profile.modalities.image_in)); + let local_capabilities = self.local_capabilities_locked.then_some(( + self.profile.tool_calling, + self.profile.parallel_tool_calls, + self.profile.streaming_tool_chunks, + self.profile.modalities.image_in, + )); self.profile = derive_profile(&self.provider, &self.model); - if let Some((tool_calling, image_in)) = local_capabilities { + if let Some((tool_calling, parallel_tool_calls, streaming_tool_chunks, image_in)) = + local_capabilities + { self.profile.tool_calling = tool_calling; + self.profile.parallel_tool_calls = parallel_tool_calls; + self.profile.streaming_tool_chunks = streaming_tool_chunks; self.profile.modalities.image_in = image_in; } self @@ -559,12 +577,19 @@ impl OpenAiModel { /// Overrides the provider family id used in profiles and normalized errors. pub fn with_provider(mut self, provider: impl Into) -> Self { self.provider = provider.into(); - let local_capabilities = self - .local_capabilities_locked - .then_some((self.profile.tool_calling, self.profile.modalities.image_in)); + let local_capabilities = self.local_capabilities_locked.then_some(( + self.profile.tool_calling, + self.profile.parallel_tool_calls, + self.profile.streaming_tool_chunks, + self.profile.modalities.image_in, + )); self.profile = derive_profile(&self.provider, &self.model); - if let Some((tool_calling, image_in)) = local_capabilities { + if let Some((tool_calling, parallel_tool_calls, streaming_tool_chunks, image_in)) = + local_capabilities + { self.profile.tool_calling = tool_calling; + self.profile.parallel_tool_calls = parallel_tool_calls; + self.profile.streaming_tool_chunks = streaming_tool_chunks; self.profile.modalities.image_in = image_in; } self @@ -614,6 +639,7 @@ impl OpenAiModel { /// Builds an OpenAI-compatible model from a provider spec and explicit API /// key. pub fn from_spec(spec: ProviderSpec, api_key: impl Into) -> Result { + let api_key = api_key.into(); if spec.model.trim().is_empty() { return Err(TinyAgentsError::Validation( "provider spec model must not be empty".to_string(), @@ -624,6 +650,20 @@ impl OpenAiModel { "provider spec base_url must not be empty".to_string(), )); } + if spec.kind == crate::harness::providers::ProviderKind::Ollama { + let auth = if spec.requires_api_key { + AuthStyle::Bearer + } else { + AuthStyle::None + }; + return Ok(Self::local_runtime( + &spec.provider, + normalize_local_v1_base_url(spec.base_url, "http://localhost:11434")?, + api_key, + spec.model, + ) + .with_auth_style(auth)); + } Ok(Self::compatible_provider( spec.provider, api_key, @@ -797,16 +837,17 @@ impl OpenAiModel { /// `llama3.2`. pub fn ollama() -> Self { Self::ollama_at("http://localhost:11434", "llama3.2") + .expect("the built-in Ollama URL is valid") } /// An Ollama server exposed through its OpenAI-compatible HTTP API. - pub fn ollama_at(base_url: impl Into, model: impl Into) -> Self { - Self::local_runtime( + pub fn ollama_at(base_url: impl Into, model: impl Into) -> Result { + Ok(Self::local_runtime( "ollama", - normalize_local_v1_base_url(base_url.into(), "http://localhost:11434"), + normalize_local_v1_base_url(base_url.into(), "http://localhost:11434")?, "", model, - ) + )) } /// An LM Studio server exposed through its OpenAI-compatible HTTP API. @@ -817,20 +858,20 @@ impl OpenAiModel { base_url: impl Into, api_key: impl Into, model: impl Into, - ) -> Self { + ) -> Result { let api_key = api_key.into(); let auth = if api_key.trim().is_empty() { AuthStyle::None } else { AuthStyle::Bearer }; - Self::local_runtime( + Ok(Self::local_runtime( "lm_studio", - normalize_local_v1_base_url(base_url.into(), "http://localhost:1234"), + normalize_local_v1_base_url(base_url.into(), "http://localhost:1234")?, api_key, model, ) - .with_auth_style(auth) + .with_auth_style(auth)) } fn local_runtime( @@ -851,6 +892,11 @@ impl OpenAiModel { self } + #[cfg(test)] + pub(super) fn auth_config(&self) -> (&str, &AuthStyle) { + (&self.api_key, &self.auth) + } + /// Returns the default model id this instance will request. pub fn model(&self) -> &str { &self.model @@ -1109,7 +1155,7 @@ impl OpenAiModel { url: &str, ) -> Result { let mut builder = self.authorized(self.client.post(url)).json(body); - if let Some(timeout) = request_timeout(timeout_ms, false) { + if let Some(timeout) = self.effective_request_timeout(timeout_ms, false) { builder = builder.timeout(timeout); } self.send_checked(builder, "responses request", url).await @@ -1160,12 +1206,24 @@ impl OpenAiModel { ) -> Result { let url = format!("{}/chat/completions", self.base_url); let mut builder = self.authorized(self.client.post(&url)).json(body); - if let Some(timeout) = request_timeout(timeout_ms, streaming) { + if let Some(timeout) = self.effective_request_timeout(timeout_ms, streaming) { builder = builder.timeout(timeout); } self.send_checked(builder, what, &url).await } + pub(super) fn effective_request_timeout( + &self, + timeout_ms: Option, + streaming: bool, + ) -> Option { + if self.caller_owned_client && timeout_ms.is_none() { + None + } else { + request_timeout(timeout_ms, streaming) + } + } + /// Builds the chat-completions wire body for `request` under the given /// `degrade`, setting the streaming fields when `streaming` is `true`. fn build_chat_body( @@ -1278,7 +1336,7 @@ impl OpenAiModel { } } -fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String { +fn normalize_local_v1_base_url(raw: String, default_root: &str) -> Result { let trimmed = raw.trim().trim_end_matches('/'); let root = if trimmed.is_empty() { default_root.to_owned() @@ -1287,8 +1345,15 @@ fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String { } else { format!("http://{trimmed}") }; - let mut url = - reqwest::Url::parse(&root).expect("local runtime URL is normalized with a scheme"); + let mut url = reqwest::Url::parse(&root).map_err(|error| { + TinyAgentsError::Validation(format!("invalid local runtime URL `{root}`: {error}")) + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(TinyAgentsError::Validation(format!( + "local runtime URL must use http or https, got `{}`", + url.scheme() + ))); + } let mut segments: Vec<&str> = url .path() .split('/') @@ -1305,7 +1370,7 @@ fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String { url.set_path(&format!("/{}", segments.join("/"))); url.set_query(None); url.set_fragment(None); - url.to_string().trim_end_matches('/').to_owned() + Ok(url.to_string().trim_end_matches('/').to_owned()) } /// Request-shape degradations to apply when building an OpenAI wire body. diff --git a/src/lib.rs b/src/lib.rs index b29e7d3..7bcb33a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,6 +142,28 @@ pub use harness::cancel::CancellationToken; // --- Workspace isolation / sandbox hooks --- pub use harness::workspace::{SharedRootWorkspace, WorkspaceDescriptor, WorkspaceIsolation}; +// --- Harness: host capability seams (what an embedding application supplies) --- +// The ten extension points where the crate deliberately has no opinion — +// retrieval, context assembly, policy, budgets, definitions, experience, +// learning, progress delivery, failure classification, and model routing — +// each paired with an inert default so a host can adopt them one at a time. +// See `harness::host` for the scope boundaries between these and the +// crate-implemented capabilities they sit beside. +pub use harness::host::{ + AdmissionRequest, AgentDefinition, BudgetGate, BudgetLease, BudgetVerdict, CallVerdict, + ContextBlock, ContextComposer, ContextPlacement, DefinitionRegistry, DigestCaps, + ExperienceEntry, ExperienceHit, ExperienceQuery, ExperienceStore, InMemoryDefinitionRegistry, + InMemoryMemoryProvider, InputScreenRequest, InputVerdict, LearningSink, MemoryFilter, + MemoryProvider, MemoryQuery, MemoryRecord, MemoryWrite, ModelResolution, ModelResolver, + NamespaceDigest, NoopExperienceStore, NoopLearningSink, NoopProgressSink, + NoopToolOutcomeClassifier, PassthroughContextComposer, PathIntent, PathRequest, ProgressSink, + Redaction, RedactionDirection, RedactionRequest, RetryDisposition, RootContainedSecurityGate, + SecurityGate, StaticModelResolver, SystemPromptRequest, ToolCallRequest, ToolExposure, + ToolExposureRequest, ToolFailure, ToolFailureContext, ToolOutcomeClassifier, ToolOutcomeRecord, + TranscriptCommit, TurnCharge, TurnPreparation, TurnPreparationRequest, TurnRecord, + UnmeteredBudgetGate, UsageEntry, +}; + // --- Harness: durable observability (journals, status stores, sinks) --- pub use harness::observability::{ AgentCallLatency, AgentLatencyMetrics, AgentObservation, FanOutSink, HarnessEventJournal, diff --git a/tests/host_seam_hygiene.rs b/tests/host_seam_hygiene.rs new file mode 100644 index 0000000..682a3cc --- /dev/null +++ b/tests/host_seam_hygiene.rs @@ -0,0 +1,130 @@ +//! Guards the published host-capability surface against embedder vocabulary. +//! +//! `harness::host` exists so an embedding application can inject its own +//! behaviour without the crate learning anything about that application. That +//! guarantee is easy to state and easy to erode: a field name, a doc-comment +//! example, or an enum variant that encodes one host's internal concept ships +//! to docs.rs and becomes public API for everyone. +//! +//! This test is the mechanical check. It scans the seam module's source for +//! identifiers that belong to a specific embedding product rather than to a +//! general agent runtime, and fails with the offending file, line, and term. +//! +//! **Scope.** Today it covers `src/harness/host/` only, because that is the +//! whole published seam. As runtime code is relocated into this crate the +//! `SCANNED_DIRS` list must grow with it — a relocation that does not widen +//! this list has not been checked. +//! +//! **Adding a term.** Add anything that names a specific application, one of +//! its internal domains, one of its file conventions, or one of its named +//! third-party integrations. Do not add generic runtime vocabulary +//! ("orchestrator", "workspace", "session"); those are legitimately this +//! crate's own. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Directories scanned for embedder vocabulary, relative to the crate root. +const SCANNED_DIRS: &[&str] = &["src/harness/host"]; + +/// Lowercased substrings that must not appear in the scanned sources. +/// +/// Each entry is paired with why it is forbidden so a future contributor can +/// tell a real leak from an unlucky substring. +const FORBIDDEN_TERMS: &[(&str, &str)] = &[ + ("openhuman", "names a specific embedding application"), + ( + "tinyhumans", + "names a specific embedding application's vendor", + ), + ( + "integrations_agent", + "names an application-internal agent id and its dispatcher override", + ), + ( + "tokenjuice", + "names an application-internal compaction domain", + ), + ( + "subconscious", + "names an application-internal routing workload", + ), + ( + "composio", + "names a specific third-party integration provider", + ), + ( + "action_dir", + "names an application config key with no crate-side meaning; use WorkspaceDescriptor", + ), + ( + "profile.md", + "names an application file convention for prompt assembly", + ), + ( + "memory.md", + "names an application file convention for prompt assembly", + ), + ( + "soul.md", + "names an application file convention for prompt assembly", + ), + ( + "identity.md", + "names an application file convention for prompt assembly", + ), +]; + +/// Collects every `.rs` file under `dir`, recursively. +fn rust_sources(dir: &Path, into: &mut Vec) { + let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("read {}: {e}", dir.display())); + for entry in entries { + let path = entry.expect("directory entry").path(); + if path.is_dir() { + rust_sources(&path, into); + } else if path.extension().is_some_and(|ext| ext == "rs") { + into.push(path); + } + } +} + +#[test] +fn host_seam_carries_no_embedder_vocabulary() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + + let mut sources = Vec::new(); + for dir in SCANNED_DIRS { + let path = crate_root.join(dir); + assert!( + path.is_dir(), + "SCANNED_DIRS names {dir}, which does not exist — update the list" + ); + rust_sources(&path, &mut sources); + } + assert!(!sources.is_empty(), "scanned no sources; the glob is wrong"); + + let mut violations = Vec::new(); + for source in &sources { + let text = + fs::read_to_string(source).unwrap_or_else(|e| panic!("read {}: {e}", source.display())); + for (line_number, line) in text.lines().enumerate() { + let haystack = line.to_lowercase(); + for (term, reason) in FORBIDDEN_TERMS { + if haystack.contains(term) { + violations.push(format!( + "{}:{}: `{term}` — {reason}\n {}", + source.strip_prefix(crate_root).unwrap_or(source).display(), + line_number + 1, + line.trim() + )); + } + } + } + } + + assert!( + violations.is_empty(), + "embedder vocabulary reached the published host seam:\n{}", + violations.join("\n") + ); +}