From e8e7f7399a72ee60e0c085d54b6e98d0c9fcf84e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 16:49:47 +0100 Subject: [PATCH 1/7] chore: apply rust-llm-tidy default tidy pass (reorder, vis, tables, fences, links, lints) Automated rust-llm-tidy output on the whole workspace with default settings, normalized with cargo fmt. Fixes links-op reference definitions that were appended outside doc comments by placing them back inside the doc comments. Remaining lint findings (DOC001/DOC002/DOC004/DOC006) still open. --- README.MD | 9 +- src/docs/src/comparison.md | 7 +- src/docs/src/getting-started.md | 10 +- src/docs/src/guides/custom-providers.md | 12 +- src/docs/src/models-catalog.md | 4 +- src/docs/src/sandboxing.md | 23 +- src/docs/src/tools.md | 16 +- .../orchestrator-quality-gate-gpt5.md | 4 +- src/reloaded-code-agents/benches/parser.rs | 23 +- .../benches/runtime_task.rs | 155 +-- src/reloaded-code-agents/src/lib.rs | 20 +- src/reloaded-code-agents/src/loader.rs | 104 +- src/reloaded-code-agents/src/parser/mod.rs | 190 +-- .../src/parser/preprocessor.rs | 14 +- src/reloaded-code-agents/src/path/mod.rs | 4 +- src/reloaded-code-agents/src/path/resolver.rs | 60 +- .../src/runtime/builder.rs | 14 +- src/reloaded-code-agents/src/runtime/mod.rs | 10 +- src/reloaded-code-agents/src/runtime/model.rs | 60 +- src/reloaded-code-agents/src/runtime/state.rs | 46 +- src/reloaded-code-agents/src/runtime/task.rs | 60 +- src/reloaded-code-agents/src/test_helpers.rs | 22 +- src/reloaded-code-agents/src/types/config.rs | 112 +- src/reloaded-code-agents/src/types/error.rs | 46 +- src/reloaded-code-agents/src/types/mod.rs | 9 +- .../src/types/tool_settings.rs | 312 ++--- src/reloaded-code-bubblewrap/src/lib.rs | 17 +- src/reloaded-code-bubblewrap/src/probe.rs | 178 +-- .../src/profile/builder.rs | 261 ++-- .../src/profile/factory.rs | 80 +- .../src/profile/layout.rs | 52 +- .../src/profile/mod.rs | 14 +- .../src/profile/presets.rs | 119 +- .../src/profile/types.rs | 424 +++---- .../src/profile/validation.rs | 194 +-- .../src/test_helpers.rs | 380 +++--- .../src/wrap/command.rs | 26 +- src/reloaded-code-bubblewrap/src/wrap/mod.rs | 7 +- .../benches/common/corpus_large.rs | 38 +- .../benches/common/corpus_medium.rs | 116 +- .../benches/common/corpus_small.rs | 4 +- src/reloaded-code-core/benches/common/mod.rs | 24 +- .../benches/model_catalog_builder.rs | 89 +- .../benches/path_resolvers.rs | 266 ++-- src/reloaded-code-core/benches/permissions.rs | 93 +- src/reloaded-code-core/benches/tools_edit.rs | 35 +- src/reloaded-code-core/benches/tools_glob.rs | 195 +-- src/reloaded-code-core/benches/tools_grep.rs | 245 ++-- src/reloaded-code-core/benches/tools_read.rs | 49 +- src/reloaded-code-core/benches/tools_write.rs | 23 +- .../examples/system_prompt/build.rs | 3 +- .../examples/system_prompt/definitions.rs | 389 +++--- .../examples/system_prompt/mock_tools.rs | 269 +++-- .../examples/system_prompt/mod.rs | 12 +- .../examples/system_prompt/report.rs | 14 +- .../examples/system_prompt/types.rs | 67 +- .../examples/system_prompt_preview.rs | 29 +- .../examples/system_prompt_preview_compare.rs | 72 +- .../system_prompt_preview_readonly.rs | 8 +- src/reloaded-code-core/src/context/mod.rs | 17 +- .../src/context/tool_prompt/common_rules.rs | 87 +- .../src/context/tool_prompt/mod.rs | 41 +- .../src/context/tool_prompt/tool_sections.rs | 179 ++- src/reloaded-code-core/src/credentials.rs | 26 +- src/reloaded-code-core/src/custom_tool/mod.rs | 12 +- .../src/custom_tool/registry.rs | 68 +- .../src/custom_tool/test_stubs.rs | 54 +- src/reloaded-code-core/src/error.rs | 18 +- .../src/fs/blocking_impl.rs | 44 +- src/reloaded-code-core/src/fs/mod.rs | 9 +- src/reloaded-code-core/src/fs/tokio_impl.rs | 44 +- src/reloaded-code-core/src/hooks/mod.rs | 10 +- src/reloaded-code-core/src/hooks/session.rs | 18 +- src/reloaded-code-core/src/hooks/tool_hook.rs | 116 +- src/reloaded-code-core/src/lib.rs | 44 +- .../src/models/catalog/internal/builder.rs | 360 +++--- .../src/models/catalog/internal/hash_utils.rs | 20 +- .../src/models/catalog/internal/mod.rs | 15 +- .../catalog/internal/packed_model_entry.rs | 16 +- .../packed_provider_model_table_entry.rs | 12 +- .../internal/packed_provider_table_entry.rs | 12 +- .../src/models/catalog/mod.rs | 83 +- .../models/catalog/public/builder_types.rs | 298 ++--- .../src/models/catalog/public/entry.rs | 78 +- .../src/models/catalog/public/modality.rs | 4 +- .../src/models/catalog/public/model_idx.rs | 7 +- .../src/models/catalog/public/provider_idx.rs | 7 +- src/reloaded-code-core/src/models/mod.rs | 6 +- src/reloaded-code-core/src/path/allowed.rs | 86 +- .../src/path/allowed_glob/mod.rs | 17 +- .../src/path/allowed_glob/normalize.rs | 59 +- .../src/path/allowed_glob/policy.rs | 70 +- src/reloaded-code-core/src/path/mod.rs | 93 +- src/reloaded-code-core/src/permissions.rs | 258 ++-- src/reloaded-code-core/src/system_prompt.rs | 12 +- src/reloaded-code-core/src/tool_catalog.rs | 40 +- .../src/tool_context/mod.rs | 3 +- .../src/tool_metadata/bash.rs | 21 +- .../src/tool_metadata/edit.rs | 24 +- .../src/tool_metadata/glob.rs | 12 +- .../src/tool_metadata/grep.rs | 17 +- .../src/tool_metadata/mod.rs | 37 +- .../src/tool_metadata/read.rs | 51 +- .../src/tool_metadata/task.rs | 11 +- .../src/tool_metadata/todo_read.rs | 5 +- .../src/tool_metadata/todo_write.rs | 11 +- .../src/tool_metadata/webfetch.rs | 28 +- .../src/tool_metadata/write.rs | 7 +- src/reloaded-code-core/src/tools/bash/mod.rs | 216 ++-- .../src/tools/bash/tokio_impl.rs | 110 +- src/reloaded-code-core/src/tools/edit.rs | 28 +- src/reloaded-code-core/src/tools/glob.rs | 58 +- src/reloaded-code-core/src/tools/grep.rs | 225 ++-- src/reloaded-code-core/src/tools/mod.rs | 26 +- src/reloaded-code-core/src/tools/read.rs | 121 +- src/reloaded-code-core/src/tools/task.rs | 76 +- src/reloaded-code-core/src/tools/todo.rs | 162 +-- .../src/tools/webfetch/mod.rs | 181 ++- src/reloaded-code-core/src/tools/write.rs | 12 +- src/reloaded-code-core/src/util.rs | 84 +- .../src/api/catalog_sources.rs | 64 +- .../src/api/schema.rs | 16 +- .../src/cache/format.rs | 57 +- src/reloaded-code-models-dev/src/cache/mod.rs | 6 +- .../src/cache/path.rs | 3 +- .../src/cache/payload.rs | 106 +- .../src/catalog/mod.rs | 16 +- .../src/catalog/sync.rs | 84 +- src/reloaded-code-models-dev/src/error.rs | 6 +- .../src/fs/blocking_impl.rs | 12 +- src/reloaded-code-models-dev/src/fs/mod.rs | 37 +- .../src/fs/tokio_impl.rs | 12 +- src/reloaded-code-models-dev/src/lib.rs | 8 +- .../src/api_type.rs | 6 +- src/reloaded-code-provider-config/src/lib.rs | 8 +- .../src/loader.rs | 215 ++-- .../examples/serdesai-agents.rs | 2 +- .../examples/serdesai-basic.rs | 10 +- .../serdesai-custom-tool-standalone.rs | 12 +- .../examples/serdesai-custom-tool.rs | 128 +- .../examples/serdesai-sandboxed.rs | 10 +- .../examples/serdesai-task.rs | 112 +- src/reloaded-code-serdesai/src/agent_ext.rs | 126 +- .../src/agent_runtime/build.rs | 133 +- .../src/agent_runtime/mod.rs | 14 +- .../src/agent_runtime/provider_bridge/mod.rs | 1066 ++++++++--------- .../agent_runtime/provider_bridge/tests.rs | 368 +++--- .../src/agent_runtime/task.rs | 50 +- .../src/agent_runtime/test_stubs.rs | 70 +- src/reloaded-code-serdesai/src/convert.rs | 78 +- src/reloaded-code-serdesai/src/lib.rs | 31 +- src/reloaded-code-serdesai/src/mock.rs | 33 +- .../src/task/definition.rs | 42 +- src/reloaded-code-serdesai/src/task/handle.rs | 24 +- src/reloaded-code-serdesai/src/task/mod.rs | 8 +- src/reloaded-code-serdesai/src/tools/bash.rs | 43 +- src/reloaded-code-serdesai/src/tools/edit.rs | 3 +- src/reloaded-code-serdesai/src/tools/glob.rs | 65 +- src/reloaded-code-serdesai/src/tools/grep.rs | 43 +- src/reloaded-code-serdesai/src/tools/mod.rs | 20 +- src/reloaded-code-serdesai/src/tools/read.rs | 3 +- src/reloaded-code-serdesai/src/tools/todo.rs | 94 +- .../src/tools/webfetch.rs | 12 +- src/reloaded-code-serdesai/src/tools/write.rs | 3 +- 164 files changed, 6096 insertions(+), 6167 deletions(-) diff --git a/README.MD b/README.MD index 5da37b76..dc5accd7 100644 --- a/README.MD +++ b/README.MD @@ -17,7 +17,7 @@ ReloadedCode started as "an OpenCode for servers." Headless, sandboxed, and cheap to host for non-commercial use. -[OpenCode](https://opencode.ai) is a great interactive coding agent, but it's a +[OpenCode] is a great interactive coding agent, but it's a ~305 MiB TypeScript application that runs as a separate process. What if you need those same tools for a **server**? A **Discord bot**? A **CI pipeline**? A **custom product**? @@ -36,7 +36,7 @@ Shell sandboxing. Default-deny permissions. ~10 MiB footprint. ## Features - **10 Built-in tools** - read, write, edit, glob, grep, bash, webfetch, todoread, todowrite, task -- **Agents similar to [OpenCode](https://opencode.ai)** - load agent markdown files with YAML frontmatter +- **Agents similar to [OpenCode]** - load agent markdown files with YAML frontmatter - **Multi-agent delegation** - orchestrator pattern with depth-limited task chains - **Linux sandboxing** - bubblewrap profiles for shell isolation (Public Bot + Trusted Maintenance) - **Path security** - restrict file access with allowed directories and glob-based rules @@ -55,7 +55,7 @@ Shell sandboxing. Default-deny permissions. ~10 MiB footprint. reloaded-code-serdesai = "0.2" ``` -**1.** Create an agent file (markdown + YAML frontmatter similar to [OpenCode](https://opencode.ai)): +**1.** Create an agent file (markdown + YAML frontmatter similar to [OpenCode]): ```markdown --- @@ -117,7 +117,7 @@ async fn main() -> Result<(), Box> { | Crate | Version | Description | | ------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | | [**reloaded-code-core**](./src/reloaded-code-core/) | 0.2 | Framework-agnostic tool implementations, path resolvers, permissions, custom tool registry | -| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode](https://opencode.ai), typed catalog, runtime builder | +| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode], typed catalog, runtime builder | | [**reloaded-code-serdesai**](./src/reloaded-code-serdesai/) | 0.2 | SerdesAI framework integration, tool adapters, 15 provider bridges, task delegation | | [**reloaded-code-bubblewrap**](./src/reloaded-code-bubblewrap/) | 0.1 | Linux bubblewrap sandbox profiles (Public Bot + Trusted Maintenance) | | [**reloaded-code-models-dev**](./src/reloaded-code-models-dev/) | 0.1 | models.dev catalog sync with ETag caching and offline fallback | @@ -161,3 +161,4 @@ our guidelines. ## License Licensed under [Apache 2.0](./LICENSE). +[OpenCode]: https://opencode.ai diff --git a/src/docs/src/comparison.md b/src/docs/src/comparison.md index 0c14c75b..aa696221 100644 --- a/src/docs/src/comparison.md +++ b/src/docs/src/comparison.md @@ -41,7 +41,7 @@ reloaded-code is for embedding agent tools into your own applications. (`name`, `mode`, `description`, `model`, `permission`, `tool_settings`). Agent files written for [OpenCode] are drop-in compatible (add explicit permissions). See [Agents](agents.md) for the full format - reference and [Migrating from OpenCode](migration.md) for the differences. + reference and [Migrating from OpenCode] for the differences. - **Core tools** - both provide `read`, `write`, `edit`, `glob`, `grep`, `bash`, and `webfetch`. See [Tools](tools.md) for the complete tool @@ -65,7 +65,7 @@ reloaded-code uses **default-deny**: every tool is blocked unless you explicitly allow it in the agent frontmatter. There is no interactive approval flow because there is no user to prompt - the agent runs unattended. -See [Migrating from OpenCode](migration.md) for a side-by-side YAML example, +See [Migrating from OpenCode] for a side-by-side YAML example, a [portable default-deny configuration](migration.md#portable-default-deny), and a migration checklist. @@ -123,7 +123,7 @@ configuration. See [Sandboxing](sandboxing.md) for the full guide. --- Ready to get started? See [Getting Started](getting-started.md) or -[Migrating from OpenCode](migration.md). +[Migrating from OpenCode]. [OpenCode]: https://opencode.ai/ [SerdesAI]: https://crates.io/crates/serdes-ai @@ -131,3 +131,4 @@ Ready to get started? See [Getting Started](getting-started.md) or [bubblewrap]: https://github.com/containers/bubblewrap [Bun]: https://bun.sh [tokio]: https://tokio.rs +[Migrating from OpenCode]: migration.md diff --git a/src/docs/src/getting-started.md b/src/docs/src/getting-started.md index 7f02fbf1..b95bf822 100644 --- a/src/docs/src/getting-started.md +++ b/src/docs/src/getting-started.md @@ -153,7 +153,7 @@ a Rust project and an LLM API key (e.g. `OPENAI_API_KEY`). [serdesai-basic](https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-basic.rs) (without agent files) and [serdesai-agents](https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-agents.rs) - (with agent files). See [Examples](examples.md) for the full list. + (with agent files). See [Examples] for the full list. ## Custom tools @@ -224,7 +224,7 @@ cargo run --example serdesai-agents -p reloaded-code-serdesai cargo run --example serdesai-task -p reloaded-code-serdesai ``` -See [Examples](examples.md) for the full list with descriptions and +See [Examples] for the full list with descriptions and source links. ## Sandboxing for production @@ -242,10 +242,10 @@ to isolate shell execution. See [Sandboxing](sandboxing.md) for the full guide. ### Common deployment profiles - **Discord bot / chat bot** - Use the Public Bot sandbox profile - (restrictive; see [Sandboxing](sandboxing.md#the-two-profiles)) and + (restrictive; see [Sandboxing]) and `AllowedPathResolver` to limit what the LLM can do with user-provided prompts. - **CI/CD pipeline** - Use the Trusted Maintenance profile - (permissive; see [Sandboxing](sandboxing.md#the-two-profiles)) for build jobs + (permissive; see [Sandboxing]) for build jobs where you control the inputs. Explicitly mount the cache directories so that build artifacts persist between runs. @@ -274,3 +274,5 @@ reloaded-code-core = { version = "0.2", default-features = false, features = ["b [`ToolContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolContext.html [`CustomTool`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.CustomTool.html [`ToolFactory`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolFactory.html +[Examples]: examples.md +[Sandboxing]: sandboxing.md#the-two-profiles diff --git a/src/docs/src/guides/custom-providers.md b/src/docs/src/guides/custom-providers.md index 0c6323f9..c2d53811 100644 --- a/src/docs/src/guides/custom-providers.md +++ b/src/docs/src/guides/custom-providers.md @@ -50,12 +50,12 @@ Each provider must include at least one model under `models`. ### Provider fields -| Field | Type | Default | Notes | -| ------------ | ----------- | ------------------- | ------------------------------------- | -| `api_url` | string | required | Base URL for the API endpoint | -| `api_type` | string | `openai-compatible` | Maps to provider behaviour profile | -| `env` | string list | `[]` | Env var names checked for credentials | -| `models` | map | required | Models offered by this provider | +| Field | Type | Default | Notes | +| ---------- | ----------- | ------------------- | ------------------------------------- | +| `api_url` | string | required | Base URL for the API endpoint | +| `api_type` | string | `openai-compatible` | Maps to provider behaviour profile | +| `env` | string list | `[]` | Env var names checked for credentials | +| `models` | map | required | Models offered by this provider | ### api_type values diff --git a/src/docs/src/models-catalog.md b/src/docs/src/models-catalog.md index ee996d6a..06e8d421 100644 --- a/src/docs/src/models-catalog.md +++ b/src/docs/src/models-catalog.md @@ -82,8 +82,8 @@ let result = ModelsDevCatalog::load_at(&cache_path).await?; **Location** (platform default): -| Platform | Path | -| -------- | --------------------------------------------------------------- | +| Platform | Path | +| -------- | ------------------------------------------------------------ | | Linux | `~/.cache/reloaded-code/models.dev.catalog.v1.cache` | | macOS | `~/Library/Caches/reloaded-code/models.dev.catalog.v1.cache` | | Windows | `%LOCALAPPDATA%\reloaded-code\models.dev.catalog.v1.cache` | diff --git a/src/docs/src/sandboxing.md b/src/docs/src/sandboxing.md index 69277363..8cdf1e05 100644 --- a/src/docs/src/sandboxing.md +++ b/src/docs/src/sandboxing.md @@ -31,7 +31,7 @@ protection: Built on [bubblewrap](https://github.com/containers/bubblewrap), a lightweight sandboxing tool that uses Linux kernel namespaces. -- **Feature flag**: `linux-bubblewrap` (see [Feature Flags](feature-flags.md)) +- **Feature flag**: `linux-bubblewrap` (see [Feature Flags]) - **Requirement**: Linux host with `bwrap` installed The sandbox never silently falls back to host execution. If `bwrap` is missing @@ -48,7 +48,7 @@ reloaded-code-serdesai = { version = "0.2", features = ["linux-bubblewrap"] } ``` *(Also shown in [Getting Started](getting-started.md) and -[Feature Flags](feature-flags.md).)* +[Feature Flags].)* When you enable sandboxing, start with the **Public Bot** profile. @@ -262,16 +262,16 @@ full mount table, environment variables, and design rationale. ### Quick comparison -| Aspect | Public Bot | Trusted Maintenance | -| ------------------ | --------------------------------- | ----------------------------------------- | -| Use case | Untrusted / hostile input | Trusted automation | -| Network | Disabled | Enabled | -| Host filesystem | Minimal (bins, libs, workspace) | Full `/` read-only | -| Writable paths | Workspace, synthetic home, `/tmp` | Workspace, synthetic home, cache, `/tmp` | -| `/etc` visible | No | Yes (except `/etc/shadow`) | +| Aspect | Public Bot | Trusted Maintenance | +| ------------------ | --------------------------------- | ------------------------------------------------------------- | +| Use case | Untrusted / hostile input | Trusted automation | +| Network | Disabled | Enabled | +| Host filesystem | Minimal (bins, libs, workspace) | Full `/` read-only | +| Writable paths | Workspace, synthetic home, `/tmp` | Workspace, synthetic home, cache, `/tmp` | +| `/etc` visible | No | Yes (except `/etc/shadow`) | | Environment | Cleared, minimal sanitized `PATH` | Cleared, sanitized host `PATH` + XDG Base Directory variables | -| Credential mounts | Not supported | Supported (validated) | -| Safe for untrusted | **Yes** | **No** | +| Credential mounts | Not supported | Supported (validated) | +| Safe for untrusted | **Yes** | **No** | ### Under the hood @@ -328,3 +328,4 @@ and design rationale, see [Profile Reference](extra-sandboxing-notes.md). [with_linux_bwrap]: https://docs.rs/reloaded-code-serdesai/latest/reloaded_code_serdesai/struct.BashTool.html#method.with_linux_bwrap [new_with_temp_sandbox]: https://docs.rs/reloaded-code-serdesai/latest/reloaded_code_serdesai/struct.AgentBuildContext.html#method.new_with_temp_sandbox [Preset]: https://docs.rs/reloaded-code-bubblewrap/latest/reloaded_code_bubblewrap/profile/enum.Preset.html +[Feature Flags]: feature-flags.md diff --git a/src/docs/src/tools.md b/src/docs/src/tools.md index 0623b5c4..c5a9ab85 100644 --- a/src/docs/src/tools.md +++ b/src/docs/src/tools.md @@ -218,7 +218,7 @@ Reads a file, optionally with line numbers and a windowed range. **Output:** Line-numbered file content. Lines beyond `max_line_length` are truncated with `...`. -**Configurable via [tool settings](#tool-settings):** `line_numbers`, `limit`, +**Configurable via [tool settings]:** `line_numbers`, `limit`, `max_line_length` ### write @@ -269,7 +269,7 @@ crate for fast traversal. **Output:** List of matching file paths. -**Configurable via [tool settings](#tool-settings):** `limit` +**Configurable via [tool settings]:** `limit` ### grep @@ -285,7 +285,7 @@ Searches file contents by regex pattern. Returns matching lines with metadata. **Output:** Matching lines with line numbers and file paths. -**Configurable via [tool settings](#tool-settings):** `line_numbers`, `limit`, +**Configurable via [tool settings]:** `line_numbers`, `limit`, `max_line_length` ### bash @@ -303,10 +303,10 @@ Executes a shell command with timeout and captured output. **Output:** Combined stdout and stderr. Non-zero exit codes are included in the output. -**Configurable via [tool settings](#tool-settings):** `timeout_ms`, `max_timeout_ms` +**Configurable via [tool settings]:** `timeout_ms`, `max_timeout_ms` **Sandboxing:** On Linux, you can enable the `linux-bubblewrap` feature to run -commands inside a [bubblewrap] sandbox. See [Sandboxing](sandboxing.md) for details. +commands inside a [bubblewrap] sandbox. See [Sandboxing] for details. ### webfetch @@ -322,7 +322,7 @@ to markdown. **Output:** Page content as text or markdown. -**Configurable via [tool settings](#tool-settings):** `timeout_ms`, +**Configurable via [tool settings]:** `timeout_ms`, `max_timeout_ms`, `max_response_size` ### todoread / todowrite @@ -501,9 +501,11 @@ This controls which paths the tools can access: Agents use `AllowedGlobResolver` by default. If you don't need glob-based rules, `AllowedPathResolver` or `AbsolutePathResolver` are slightly faster. -For a deeper dive into path security, see [Sandboxing](sandboxing.md). +For a deeper dive into path security, see [Sandboxing]. [bubblewrap]: https://github.com/containers/bubblewrap [create_todo_tools]: https://docs.rs/reloaded-code-serdesai/latest/reloaded_code_serdesai/tools/todo/fn.create_todo_tools.html [reloaded-code-core]: https://docs.rs/reloaded-code-core [reloaded-code-serdesai]: https://docs.rs/reloaded-code-serdesai +[tool settings]: #tool-settings +[Sandboxing]: sandboxing.md diff --git a/src/reloaded-code-agents/benches/fixtures/orchestrator-quality-gate-gpt5.md b/src/reloaded-code-agents/benches/fixtures/orchestrator-quality-gate-gpt5.md index 6d1f4294..5e2e20e6 100644 --- a/src/reloaded-code-agents/benches/fixtures/orchestrator-quality-gate-gpt5.md +++ b/src/reloaded-code-agents/benches/fixtures/orchestrator-quality-gate-gpt5.md @@ -119,9 +119,9 @@ Description of issue Detailed explanation of the problem and why it matters **Impact:** What could go wrong **Fix:** -```lang +~~~lang // replacement code if applicable -``` +~~~ ## Test Issues [basic|no] - [PASS|FAIL|FORBIDDEN_TESTS_FOUND] diff --git a/src/reloaded-code-agents/benches/parser.rs b/src/reloaded-code-agents/benches/parser.rs index 389c1b64..f864a683 100644 --- a/src/reloaded-code-agents/benches/parser.rs +++ b/src/reloaded-code-agents/benches/parser.rs @@ -1,18 +1,13 @@ //! Benchmarks for agent parsing. +criterion_group!(benches, benchmark_parse_frontmatter); + +criterion_main!(benches); + use core::hint::black_box; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use reloaded_code_agents::{AgentCatalog, AgentLoader}; -/// Loads a real agent fixture file at runtime. -fn load_fixture() -> String { - std::fs::read_to_string(concat!( - env!("CARGO_MANIFEST_DIR"), - "/benches/fixtures/orchestrator-quality-gate-gpt5.md" - )) - .expect("failed to load fixture file") -} - fn benchmark_parse_frontmatter(c: &mut Criterion) { let real_lf = load_fixture(); let real_crlf = real_lf.replace('\n', "\r\n"); @@ -38,5 +33,11 @@ fn benchmark_parse_frontmatter(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, benchmark_parse_frontmatter); -criterion_main!(benches); +/// Loads a real agent fixture file at runtime. +fn load_fixture() -> String { + std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/benches/fixtures/orchestrator-quality-gate-gpt5.md" + )) + .expect("failed to load fixture file") +} diff --git a/src/reloaded-code-agents/benches/runtime_task.rs b/src/reloaded-code-agents/benches/runtime_task.rs index 7a3fb06a..7d9479b0 100644 --- a/src/reloaded-code-agents/benches/runtime_task.rs +++ b/src/reloaded-code-agents/benches/runtime_task.rs @@ -4,6 +4,10 @@ //! [`AgentRuntime::summarize_callable_targets`], and //! [`AgentRuntime::can_delegate_to`] across varying agent counts. +criterion_group!(benches, bench_runtime_task_caches); + +criterion_main!(benches); + use ahash::AHashMap; use core::hint::black_box; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; @@ -14,46 +18,46 @@ use reloaded_code_agents::{ use reloaded_code_core::permissions::PermissionAction; use reloaded_code_core::tool_metadata::{read as read_meta, task as task_meta}; -/// Build a minimal [`AgentConfig`] for benchmark fixtures. +/// Benchmark cached delegation queries against runtimes of 16, 64, and 256 agents. /// -/// `permission` controls tool-access rules; all other fields are filled -/// with placeholder values suitable for performance measurement only. -fn build_agent( - name: &str, - mode: AgentMode, - permission: IndexMap, -) -> AgentConfig { - AgentConfig { - name: name.into(), - mode, - description: format!("{name} description").into(), - model: None, - hidden: false, - temperature: None, - top_p: None, - permission, - options: AHashMap::new(), - tool_settings: AgentToolSettings::default(), - prompt: Default::default(), - } -} +/// Measures four operations: +/// - **allowed_tools** – full tool-set resolution for the `caller` agent. +/// - **summaries** – callable-target summary strings for `caller`. +/// - **can_delegate_hit** – pattern-match hit (`caller` → `review-003`). +/// - **can_delegate_miss** – pattern-match miss (`caller` → `misc-002`). +fn bench_runtime_task_caches(c: &mut Criterion) { + let mut group = c.benchmark_group("runtime/task_caches"); -/// Create a permission map that denies all tools by default, but allows -/// pattern-matched delegation to agents named `review-*` or `worker-*` -/// via the task tool, and blanket-allows the read tool. -fn patterned_task_permission() -> IndexMap { - let mut patterns = IndexMap::new(); - patterns.insert("*".to_string(), PermissionAction::Deny); - patterns.insert("review-*".to_string(), PermissionAction::Allow); - patterns.insert("worker-*".to_string(), PermissionAction::Allow); + for agent_count in [16_usize, 64, 256] { + let runtime = build_runtime(agent_count); + group.throughput(Throughput::Elements(1)); - IndexMap::from([ - (task_meta::NAME.into(), PermissionRule::Pattern(patterns)), - ( - read_meta::NAME.into(), - PermissionRule::Action(PermissionAction::Allow), - ), - ]) + group.bench_with_input( + BenchmarkId::new("allowed_tools", agent_count), + &runtime, + |b, runtime| b.iter(|| black_box(runtime.allowed_tools("caller"))), + ); + + group.bench_with_input( + BenchmarkId::new("summaries", agent_count), + &runtime, + |b, runtime| b.iter(|| black_box(runtime.summarize_callable_targets("caller"))), + ); + + group.bench_with_input( + BenchmarkId::new("can_delegate_hit", agent_count), + &runtime, + |b, runtime| b.iter(|| black_box(runtime.can_delegate_to("caller", "review-003"))), + ); + + group.bench_with_input( + BenchmarkId::new("can_delegate_miss", agent_count), + &runtime, + |b, runtime| b.iter(|| black_box(runtime.can_delegate_to("caller", "misc-002"))), + ); + } + + group.finish(); } /// Build an [`AgentRuntime`] with one `caller` primary agent and @@ -90,47 +94,44 @@ fn build_runtime(agent_count: usize) -> reloaded_code_agents::AgentRuntime { .expect("benchmark fixture should not fail pattern expansion") } -/// Benchmark cached delegation queries against runtimes of 16, 64, and 256 agents. +/// Build a minimal [`AgentConfig`] for benchmark fixtures. /// -/// Measures four operations: -/// - **allowed_tools** – full tool-set resolution for the `caller` agent. -/// - **summaries** – callable-target summary strings for `caller`. -/// - **can_delegate_hit** – pattern-match hit (`caller` → `review-003`). -/// - **can_delegate_miss** – pattern-match miss (`caller` → `misc-002`). -fn bench_runtime_task_caches(c: &mut Criterion) { - let mut group = c.benchmark_group("runtime/task_caches"); - - for agent_count in [16_usize, 64, 256] { - let runtime = build_runtime(agent_count); - group.throughput(Throughput::Elements(1)); - - group.bench_with_input( - BenchmarkId::new("allowed_tools", agent_count), - &runtime, - |b, runtime| b.iter(|| black_box(runtime.allowed_tools("caller"))), - ); - - group.bench_with_input( - BenchmarkId::new("summaries", agent_count), - &runtime, - |b, runtime| b.iter(|| black_box(runtime.summarize_callable_targets("caller"))), - ); - - group.bench_with_input( - BenchmarkId::new("can_delegate_hit", agent_count), - &runtime, - |b, runtime| b.iter(|| black_box(runtime.can_delegate_to("caller", "review-003"))), - ); - - group.bench_with_input( - BenchmarkId::new("can_delegate_miss", agent_count), - &runtime, - |b, runtime| b.iter(|| black_box(runtime.can_delegate_to("caller", "misc-002"))), - ); +/// `permission` controls tool-access rules; all other fields are filled +/// with placeholder values suitable for performance measurement only. +fn build_agent( + name: &str, + mode: AgentMode, + permission: IndexMap, +) -> AgentConfig { + AgentConfig { + name: name.into(), + mode, + description: format!("{name} description").into(), + model: None, + hidden: false, + temperature: None, + top_p: None, + permission, + options: AHashMap::new(), + tool_settings: AgentToolSettings::default(), + prompt: Default::default(), } - - group.finish(); } -criterion_group!(benches, bench_runtime_task_caches); -criterion_main!(benches); +/// Create a permission map that denies all tools by default, but allows +/// pattern-matched delegation to agents named `review-*` or `worker-*` +/// via the task tool, and blanket-allows the read tool. +fn patterned_task_permission() -> IndexMap { + let mut patterns = IndexMap::new(); + patterns.insert("*".to_string(), PermissionAction::Deny); + patterns.insert("review-*".to_string(), PermissionAction::Allow); + patterns.insert("worker-*".to_string(), PermissionAction::Allow); + + IndexMap::from([ + (task_meta::NAME.into(), PermissionRule::Pattern(patterns)), + ( + read_meta::NAME.into(), + PermissionRule::Action(PermissionAction::Allow), + ), + ]) +} diff --git a/src/reloaded-code-agents/src/lib.rs b/src/reloaded-code-agents/src/lib.rs index 9227bdbf..a77dc93d 100644 --- a/src/reloaded-code-agents/src/lib.rs +++ b/src/reloaded-code-agents/src/lib.rs @@ -1,15 +1,5 @@ #![doc = include_str!(concat!("../", env!("CARGO_PKG_README")))] -mod catalog; -mod extensions; -mod loader; -mod parser; -mod path; -mod runtime; -#[cfg(test)] -mod test_helpers; -mod types; - pub use catalog::AgentCatalog; pub use extensions::RulesetExt; pub use loader::AgentLoader; @@ -25,3 +15,13 @@ pub use types::{ BashToolSettings, GlobToolSettings, GrepToolSettings, PermissionRule, ReadToolSettings, WebFetchToolSettings, }; + +mod catalog; +mod extensions; +mod loader; +mod parser; +mod path; +mod runtime; +#[cfg(test)] +mod test_helpers; +mod types; diff --git a/src/reloaded-code-agents/src/loader.rs b/src/reloaded-code-agents/src/loader.rs index 9f3dddbe..383710c0 100644 --- a/src/reloaded-code-agents/src/loader.rs +++ b/src/reloaded-code-agents/src/loader.rs @@ -269,6 +269,29 @@ impl AgentLoader { } } +/// Strict parser for catalog-only string loading (validates non-empty name). +fn config_from_str_strict( + markdown: impl Into, + default_name: impl Into>, +) -> AgentLoadResult { + let config = parse_agent_config(markdown.into(), default_name) + .map_err(|err| map_parse_error(None, err))?; + if config.name.is_empty() { + return Err(AgentLoadError::schema_validation( + None, + "agent name is empty", + )); + } + Ok(config) +} + +/// Loads a single agent configuration from a file. +fn load_agent_file(path: &Path, name: impl Into>) -> AgentLoadResult { + let content = + fs::read_to_string(path).map_err(|e| AgentLoadError::io(Some(path.to_path_buf()), e))?; + parse_agent_config(content, name).map_err(|err| map_parse_error(Some(path.to_path_buf()), err)) +} + /// Shared directory scan helper used by catalog loading. fn load_directory_with( dir: &Path, @@ -331,58 +354,6 @@ fn load_directory_with( Ok(()) } -/// Shared parse helper that reuses existing loader parsing. -fn parse_agent_config( - content: String, - default_name: impl Into>, -) -> Result { - let result = parse_agent::(content)?; - Ok(AgentConfig::from_raw( - default_name, - result.data, - result.content, - )) -} - -fn map_parse_error(path: Option, err: AgentParseError) -> AgentLoadError { - match err { - AgentParseError::SchemaValidation { message } => { - AgentLoadError::schema_validation(path, message) - } - other => AgentLoadError::parse(path, other), - } -} - -/// Loads a single agent configuration from a file. -fn load_agent_file(path: &Path, name: impl Into>) -> AgentLoadResult { - let content = - fs::read_to_string(path).map_err(|e| AgentLoadError::io(Some(path.to_path_buf()), e))?; - parse_agent_config(content, name).map_err(|err| map_parse_error(Some(path.to_path_buf()), err)) -} - -/// Strict parser for catalog-only string loading (validates non-empty name). -fn config_from_str_strict( - markdown: impl Into, - default_name: impl Into>, -) -> AgentLoadResult { - let config = parse_agent_config(markdown.into(), default_name) - .map_err(|err| map_parse_error(None, err))?; - if config.name.is_empty() { - return Err(AgentLoadError::schema_validation( - None, - "agent name is empty", - )); - } - Ok(config) -} - -/// Checks if a relative path matches `agent/**/*.md` or `agents/**/*.md`. -fn matches_agent_pattern(rel_path: &str) -> bool { - let is_agent_dir = rel_path.starts_with("agent/") || rel_path.starts_with("agents/"); - let is_md_file = rel_path.ends_with(".md"); - is_agent_dir && is_md_file -} - /// Derives agent name from relative path. /// /// Strips leading `agent/` or `agents/` segment and `.md` extension. @@ -409,6 +380,35 @@ fn derive_agent_name_from_rel(rel_path: &str) -> Option { } } +fn map_parse_error(path: Option, err: AgentParseError) -> AgentLoadError { + match err { + AgentParseError::SchemaValidation { message } => { + AgentLoadError::schema_validation(path, message) + } + other => AgentLoadError::parse(path, other), + } +} + +/// Checks if a relative path matches `agent/**/*.md` or `agents/**/*.md`. +fn matches_agent_pattern(rel_path: &str) -> bool { + let is_agent_dir = rel_path.starts_with("agent/") || rel_path.starts_with("agents/"); + let is_md_file = rel_path.ends_with(".md"); + is_agent_dir && is_md_file +} + +/// Shared parse helper that reuses existing loader parsing. +fn parse_agent_config( + content: String, + default_name: impl Into>, +) -> Result { + let result = parse_agent::(content)?; + Ok(AgentConfig::from_raw( + default_name, + result.data, + result.content, + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-agents/src/parser/mod.rs b/src/reloaded-code-agents/src/parser/mod.rs index 7a418b7f..f186bd2e 100644 --- a/src/reloaded-code-agents/src/parser/mod.rs +++ b/src/reloaded-code-agents/src/parser/mod.rs @@ -17,14 +17,14 @@ //! - Trims leading/trailing ASCII whitespace from the body. //! - Preprocesses YAML before deserialization (see [`preprocessor`]). -mod preprocessor; - use crlf_to_lf_inplace::crlf_to_lf_inplace; use preprocessor::preprocess_frontmatter_yaml; use serde::de::DeserializeOwned; use serde_yaml::Value; use thiserror::Error; +mod preprocessor; + /// Parser error variants independent of file paths. #[derive(Debug, Error)] pub enum AgentParseError { @@ -57,6 +57,13 @@ pub(crate) struct AgentParseResult { pub(crate) content: String, } +#[derive(Clone, Copy)] +struct FrontmatterOffsets { + yaml_start: usize, + yaml_end: usize, + body_start: usize, +} + /// Path-free agent parsing function. pub(crate) fn parse_agent( mut content: String, @@ -91,68 +98,50 @@ pub(crate) fn parse_agent( }) } -/// Validates frontmatter is compatible with headless operation. -/// -/// Rejects features requiring user interaction (e.g., "ask" permissions) -/// that are unsupported in non-interactive contexts. -fn validate_headless_compatibility(frontmatter: &Value) -> Result<(), AgentParseError> { - // Skip if root isn't a mapping - let Value::Mapping(root) = frontmatter else { - return Ok(()); - }; +/// Extracts the body by mutating the original string in-place. +/// Reuses the existing allocation and leaves only the trimmed body. +#[inline] +fn extract_body_inplace(mut content: String, body_start: usize) -> String { + if body_start >= content.len() { + content.clear(); + return content; + } - let permission_key = Value::String("permission".to_string()); - let task_key = Value::String("task".to_string()); + let len = content.len(); + let bytes = content.as_bytes(); + let mut start_offset = body_start; + let mut end_offset = len; - // Extract permission.task for validation - // - // ```yaml - // permission: - // task: # e.g., "allow", "deny", or "ask" - // ``` - // - // or: - // - // ```yaml - // permission: - // task: - // : # e.g., "*": "ask" - // ``` - // - // See `PermissionRule` for the target type. - let Some(Value::Mapping(permission_map)) = root.get(&permission_key) else { - return Ok(()); - }; - let Some(task_rule) = permission_map.get(&task_key) else { - return Ok(()); - }; + // UTF-8 byte classes: + // | Range | Meaning | `is_ascii_whitespace()` | + // |-------------|--------------------------|--------------------------| + // | `0x00..=7F` | ASCII / single-byte UTF-8| can be true | + // | `0x80..=BF` | UTF-8 continuation byte | always false | + // | `0xC2..=F4` | UTF-8 leading byte | always false | + // Therefore ASCII byte-wise trimming cannot cut through a multibyte code point. + while start_offset < len && bytes[start_offset].is_ascii_whitespace() { + start_offset += 1; + } + while end_offset > start_offset && bytes[end_offset - 1].is_ascii_whitespace() { + end_offset -= 1; + } - // Reject "ask" - requires interactive user confirmation - if task_rule_contains_ask(task_rule) { - return Err(AgentParseError::SchemaValidation { - message: "permission.task: ask is unsupported; use allow or deny".to_string(), - }); + debug_assert!(content.is_char_boundary(body_start)); + debug_assert!(content.is_char_boundary(start_offset)); + debug_assert!(content.is_char_boundary(end_offset)); + + let body_len = end_offset - start_offset; + if start_offset == 0 && body_len == len { + return content; } - Ok(()) -} -fn task_rule_contains_ask(rule: &Value) -> bool { - match rule { - // Scalar: `task: ask` - Value::String(action) => action.eq_ignore_ascii_case("ask"), - // Mapping: `task: "*": ask` - Value::Mapping(patterns) => patterns.values().any( - |value| matches!(value, Value::String(action) if action.eq_ignore_ascii_case("ask")), - ), - _ => false, + unsafe { + let vec = content.as_mut_vec(); + core::ptr::copy(vec.as_ptr().add(start_offset), vec.as_mut_ptr(), body_len); + vec.set_len(body_len); } -} -#[derive(Clone, Copy)] -struct FrontmatterOffsets { - yaml_start: usize, - yaml_end: usize, - body_start: usize, + content } #[inline] @@ -199,50 +188,61 @@ fn find_frontmatter_offsets(content: &str) -> Option { }) } -/// Extracts the body by mutating the original string in-place. -/// Reuses the existing allocation and leaves only the trimmed body. -#[inline] -fn extract_body_inplace(mut content: String, body_start: usize) -> String { - if body_start >= content.len() { - content.clear(); - return content; - } - - let len = content.len(); - let bytes = content.as_bytes(); - let mut start_offset = body_start; - let mut end_offset = len; +/// Validates frontmatter is compatible with headless operation. +/// +/// Rejects features requiring user interaction (e.g., "ask" permissions) +/// that are unsupported in non-interactive contexts. +fn validate_headless_compatibility(frontmatter: &Value) -> Result<(), AgentParseError> { + // Skip if root isn't a mapping + let Value::Mapping(root) = frontmatter else { + return Ok(()); + }; - // UTF-8 byte classes: - // | Range | Meaning | `is_ascii_whitespace()` | - // |-------------|--------------------------|--------------------------| - // | `0x00..=7F` | ASCII / single-byte UTF-8| can be true | - // | `0x80..=BF` | UTF-8 continuation byte | always false | - // | `0xC2..=F4` | UTF-8 leading byte | always false | - // Therefore ASCII byte-wise trimming cannot cut through a multibyte code point. - while start_offset < len && bytes[start_offset].is_ascii_whitespace() { - start_offset += 1; - } - while end_offset > start_offset && bytes[end_offset - 1].is_ascii_whitespace() { - end_offset -= 1; - } + let permission_key = Value::String("permission".to_string()); + let task_key = Value::String("task".to_string()); - debug_assert!(content.is_char_boundary(body_start)); - debug_assert!(content.is_char_boundary(start_offset)); - debug_assert!(content.is_char_boundary(end_offset)); + // Extract permission.task for validation + // + // ```yaml + // permission: + // task: # e.g., "allow", "deny", or "ask" + // ``` + // + // or: + // + // ```yaml + // permission: + // task: + // : # e.g., "*": "ask" + // ``` + // + // See `PermissionRule` for the target type. + let Some(Value::Mapping(permission_map)) = root.get(&permission_key) else { + return Ok(()); + }; + let Some(task_rule) = permission_map.get(&task_key) else { + return Ok(()); + }; - let body_len = end_offset - start_offset; - if start_offset == 0 && body_len == len { - return content; + // Reject "ask" - requires interactive user confirmation + if task_rule_contains_ask(task_rule) { + return Err(AgentParseError::SchemaValidation { + message: "permission.task: ask is unsupported; use allow or deny".to_string(), + }); } + Ok(()) +} - unsafe { - let vec = content.as_mut_vec(); - core::ptr::copy(vec.as_ptr().add(start_offset), vec.as_mut_ptr(), body_len); - vec.set_len(body_len); +fn task_rule_contains_ask(rule: &Value) -> bool { + match rule { + // Scalar: `task: ask` + Value::String(action) => action.eq_ignore_ascii_case("ask"), + // Mapping: `task: "*": ask` + Value::Mapping(patterns) => patterns.values().any( + |value| matches!(value, Value::String(action) if action.eq_ignore_ascii_case("ask")), + ), + _ => false, } - - content } #[cfg(test)] diff --git a/src/reloaded-code-agents/src/parser/preprocessor.rs b/src/reloaded-code-agents/src/parser/preprocessor.rs index d55e8b3f..495c8b5b 100644 --- a/src/reloaded-code-agents/src/parser/preprocessor.rs +++ b/src/reloaded-code-agents/src/parser/preprocessor.rs @@ -51,6 +51,13 @@ use std::borrow::Cow; +struct FirstBlockScalar<'a> { + line_start: usize, + rest_start: usize, + key: &'a str, + value: &'a str, +} + /// Rewrites ambiguous frontmatter values so YAML parsing stays unambiguous. pub(super) fn preprocess_frontmatter_yaml(input: &str) -> Cow<'_, str> { if input.is_empty() { @@ -102,13 +109,6 @@ fn convert_block_scalars(input: &str) -> Option { Some(out) } -struct FirstBlockScalar<'a> { - line_start: usize, - rest_start: usize, - key: &'a str, - value: &'a str, -} - /// Finds the first line that must be rewritten and returns its offsets. fn find_first_block_scalar(input: &str) -> Option> { let input_len = input.len(); diff --git a/src/reloaded-code-agents/src/path/mod.rs b/src/reloaded-code-agents/src/path/mod.rs index 3caec212..6f604324 100644 --- a/src/reloaded-code-agents/src/path/mod.rs +++ b/src/reloaded-code-agents/src/path/mod.rs @@ -3,6 +3,6 @@ //! Re-exports [`FileToolResolver`] and [`build_resolver_for_tool`] from the //! `resolver` submodule. See that module for optimisation-tier details. -mod resolver; - pub use resolver::{build_resolver_for_tool, FileToolResolver}; + +mod resolver; diff --git a/src/reloaded-code-agents/src/path/resolver.rs b/src/reloaded-code-agents/src/path/resolver.rs index ea7ab2c6..a55d4633 100644 --- a/src/reloaded-code-agents/src/path/resolver.rs +++ b/src/reloaded-code-agents/src/path/resolver.rs @@ -6,13 +6,13 @@ //! //! # Optimisation tiers //! -//! | Config pattern | Resolver | Cost | -//! |------------------------------------|-------------------------------|------------------| -//! | No config for tool | `AllowedPathResolver([root])` | prefix check | -//! | `Action(Allow)` | `AllowedPathResolver([root])` | prefix check | -//! | Pattern `**` with Allow | `AllowedPathResolver([root])` | prefix check | -//! | `/**` with Allow | `AbsolutePathResolver` | zero | -//! | Otherwise | `AllowedGlobResolver` | glob matching | +//! | Config pattern | Resolver | Cost | +//! | ----------------------- | ----------------------------- | ------------- | +//! | No config for tool | `AllowedPathResolver([root])` | prefix check | +//! | `Action(Allow)` | `AllowedPathResolver([root])` | prefix check | +//! | Pattern `**` with Allow | `AllowedPathResolver([root])` | prefix check | +//! | `/**` with Allow | `AbsolutePathResolver` | zero | +//! | Otherwise | `AllowedGlobResolver` | glob matching | use crate::types::PermissionRule; use indexmap::IndexMap; @@ -133,19 +133,20 @@ pub fn build_resolver_for_tool( } } -/// Checks if any pattern is `/**` (unrestricted access to all absolute paths). -/// -/// Returns `Some(AbsolutePathResolver)` if found, `None` otherwise. -fn try_globstar_optimisation( +/// Builds a `GlobPolicy` from a pattern map. +fn build_glob_policy( patterns: &IndexMap, -) -> Result, ToolError> { - for pattern in patterns.keys() { - let expanded = expand_shell(pattern)?; - if expanded.to_string_lossy() == "/**" { - return Ok(Some(AbsolutePathResolver)); - } + workspace_root: &Path, +) -> Result { + let mut builder = GlobPolicy::builder_with_base(workspace_root)?; + for (pattern, action) in patterns { + let rule_action = match action { + PermissionAction::Allow => RuleAction::Allow, + PermissionAction::Deny => RuleAction::Deny, + }; + builder = builder.add(pattern, rule_action)?; } - Ok(None) + builder.build() } /// Checks if the pattern map contains exactly one pattern "**" (bare globstar). @@ -163,20 +164,19 @@ fn is_bare_globstar(patterns: &IndexMap) -> bool { false } -/// Builds a `GlobPolicy` from a pattern map. -fn build_glob_policy( +/// Checks if any pattern is `/**` (unrestricted access to all absolute paths). +/// +/// Returns `Some(AbsolutePathResolver)` if found, `None` otherwise. +fn try_globstar_optimisation( patterns: &IndexMap, - workspace_root: &Path, -) -> Result { - let mut builder = GlobPolicy::builder_with_base(workspace_root)?; - for (pattern, action) in patterns { - let rule_action = match action { - PermissionAction::Allow => RuleAction::Allow, - PermissionAction::Deny => RuleAction::Deny, - }; - builder = builder.add(pattern, rule_action)?; +) -> Result, ToolError> { + for pattern in patterns.keys() { + let expanded = expand_shell(pattern)?; + if expanded.to_string_lossy() == "/**" { + return Ok(Some(AbsolutePathResolver)); + } } - builder.build() + Ok(None) } #[cfg(test)] diff --git a/src/reloaded-code-agents/src/runtime/builder.rs b/src/reloaded-code-agents/src/runtime/builder.rs index 332ce8ec..f4ed539d 100644 --- a/src/reloaded-code-agents/src/runtime/builder.rs +++ b/src/reloaded-code-agents/src/runtime/builder.rs @@ -20,13 +20,6 @@ pub struct AgentRuntimeBuilder { hooks: HookSet, } -impl Default for AgentRuntimeBuilder { - #[inline] - fn default() -> Self { - Self::new() - } -} - impl AgentRuntimeBuilder { /// Creates a builder with empty catalog, empty defaults, default Task settings, and the standard tool set. #[inline] @@ -110,6 +103,13 @@ impl AgentRuntimeBuilder { } } +impl Default for AgentRuntimeBuilder { + #[inline] + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { use super::AgentRuntimeBuilder; diff --git a/src/reloaded-code-agents/src/runtime/mod.rs b/src/reloaded-code-agents/src/runtime/mod.rs index 744098c9..ff362bc0 100644 --- a/src/reloaded-code-agents/src/runtime/mod.rs +++ b/src/reloaded-code-agents/src/runtime/mod.rs @@ -36,13 +36,13 @@ //! # } //! ``` -mod builder; -mod model; -mod state; -mod task; - pub use builder::AgentRuntimeBuilder; pub use model::{resolve_model_with_catalog, ModelResolutionError, ResolvedModel}; pub use reloaded_code_core::TaskSettings; pub use state::{AgentDefaults, AgentRuntime}; pub use task::{callable_targets, summarize_callable_targets, TaskTargetSummary}; + +mod builder; +mod model; +mod state; +mod task; diff --git a/src/reloaded-code-agents/src/runtime/model.rs b/src/reloaded-code-agents/src/runtime/model.rs index 6a91a7a2..3cc56595 100644 --- a/src/reloaded-code-agents/src/runtime/model.rs +++ b/src/reloaded-code-agents/src/runtime/model.rs @@ -33,36 +33,6 @@ use crate::AgentConfig; use reloaded_code_core::models::ModelCatalog; -/// A model identifier that's been validated against your catalog. -/// -/// Use [`provider()`][`Self::provider()`] and [`model()`][`Self::model()`] to get the -/// parts, or [`slash_spec()`][`Self::slash_spec()`] for the combined `provider/model-id` string. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedModel { - provider: Box, - model: Box, -} - -impl ResolvedModel { - /// Returns the provider (e.g., `openai`). - #[inline] - pub fn provider(&self) -> &str { - &self.provider - } - - /// Returns the model name within the provider. - #[inline] - pub fn model(&self) -> &str { - &self.model - } - - /// Returns `provider/model-id` format. - #[inline] - pub fn slash_spec(&self) -> String { - format!("{}/{}", self.provider, self.model) - } -} - /// Errors when picking or validating a model. #[derive(Debug)] #[non_exhaustive] @@ -103,6 +73,36 @@ pub enum ModelResolutionError { }, } +/// A model identifier that's been validated against your catalog. +/// +/// Use [`provider()`][`Self::provider()`] and [`model()`][`Self::model()`] to get the +/// parts, or [`slash_spec()`][`Self::slash_spec()`] for the combined `provider/model-id` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedModel { + provider: Box, + model: Box, +} + +impl ResolvedModel { + /// Returns the provider (e.g., `openai`). + #[inline] + pub fn provider(&self) -> &str { + &self.provider + } + + /// Returns the model name within the provider. + #[inline] + pub fn model(&self) -> &str { + &self.model + } + + /// Returns `provider/model-id` format. + #[inline] + pub fn slash_spec(&self) -> String { + format!("{}/{}", self.provider, self.model) + } +} + impl core::fmt::Display for ModelResolutionError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { diff --git a/src/reloaded-code-agents/src/runtime/state.rs b/src/reloaded-code-agents/src/runtime/state.rs index e0423527..d6eeb2c9 100644 --- a/src/reloaded-code-agents/src/runtime/state.rs +++ b/src/reloaded-code-agents/src/runtime/state.rs @@ -13,29 +13,6 @@ use reloaded_code_core::HookSet; use reloaded_code_core::{SharedToolRegistry, TaskSettings, ToolCatalogEntry}; use std::sync::Arc; -/// Default settings used when an agent doesn't specify them. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct AgentDefaults { - /// Default model in `provider/model-id` format. - pub model: Option>, - /// Default sampling temperature. - pub temperature: Option, - /// Default nucleus sampling top-p. - pub top_p: Option, -} - -impl AgentDefaults { - /// Creates defaults with only a model specified; temperature and top_p inherit provider defaults. - #[inline] - pub fn with_model(model: impl Into>) -> Self { - Self { - model: Some(model.into()), - temperature: None, - top_p: None, - } - } -} - /// Your loaded agents plus their default settings, Task settings, and available tools. #[derive(Debug, Clone)] pub struct AgentRuntime { @@ -50,6 +27,17 @@ pub struct AgentRuntime { callable_target_summaries_by_caller: AHashMap>, } +/// Default settings used when an agent doesn't specify them. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AgentDefaults { + /// Default model in `provider/model-id` format. + pub model: Option>, + /// Default sampling temperature. + pub temperature: Option, + /// Default nucleus sampling top-p. + pub top_p: Option, +} + impl AgentRuntime { #[inline] pub(super) fn from_parts( @@ -192,3 +180,15 @@ impl AgentRuntime { }) } } + +impl AgentDefaults { + /// Creates defaults with only a model specified; temperature and top_p inherit provider defaults. + #[inline] + pub fn with_model(model: impl Into>) -> Self { + Self { + model: Some(model.into()), + temperature: None, + top_p: None, + } + } +} diff --git a/src/reloaded-code-agents/src/runtime/task.rs b/src/reloaded-code-agents/src/runtime/task.rs index 5fb4e1e1..5f17064f 100644 --- a/src/reloaded-code-agents/src/runtime/task.rs +++ b/src/reloaded-code-agents/src/runtime/task.rs @@ -126,17 +126,26 @@ pub(super) fn build_runtime_task_caches( (allowed_tools_by_caller, callable_target_summaries_by_caller) } -fn summarize_targets(callable: Vec<&AgentConfig>) -> Vec { - let mut summaries = Vec::with_capacity(callable.len()); +fn collect_allowed_tools( + tools: &[ToolCatalogEntry], + task_rules: &Ruleset, + task_is_callable: bool, +) -> Vec { + let mut allowed = Vec::with_capacity(tools.len()); - for target in callable { - summaries.push(TaskTargetSummary { - name: target.name.clone(), - description: target.description.clone(), - }); + for entry in tools { + let is_allowed = match entry.kind { + // Task is target-scoped, so wildcard tool filtering alone is not enough. + ToolCatalogKind::Task => task_is_callable, + _ => task_rules.is_allowed(entry.name, "*"), + }; + + if is_allowed { + allowed.push(*entry); + } } - summaries + allowed } fn filter_callable_targets<'a>( @@ -158,6 +167,19 @@ fn sorted_agents(catalog: &AgentCatalog) -> Vec<&AgentConfig> { agents } +fn summarize_targets(callable: Vec<&AgentConfig>) -> Vec { + let mut summaries = Vec::with_capacity(callable.len()); + + for target in callable { + summaries.push(TaskTargetSummary { + name: target.name.clone(), + description: target.description.clone(), + }); + } + + summaries +} + fn target_is_callable( target: &AgentConfig, task_rules: &Ruleset, @@ -168,28 +190,6 @@ fn target_is_callable( || task_rules.is_allowed(task_meta::NAME, target.name.as_ref())) } -fn collect_allowed_tools( - tools: &[ToolCatalogEntry], - task_rules: &Ruleset, - task_is_callable: bool, -) -> Vec { - let mut allowed = Vec::with_capacity(tools.len()); - - for entry in tools { - let is_allowed = match entry.kind { - // Task is target-scoped, so wildcard tool filtering alone is not enough. - ToolCatalogKind::Task => task_is_callable, - _ => task_rules.is_allowed(entry.name, "*"), - }; - - if is_allowed { - allowed.push(*entry); - } - } - - allowed -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-agents/src/test_helpers.rs b/src/reloaded-code-agents/src/test_helpers.rs index fc728048..aeaae5c0 100644 --- a/src/reloaded-code-agents/src/test_helpers.rs +++ b/src/reloaded-code-agents/src/test_helpers.rs @@ -50,6 +50,17 @@ pub(crate) fn allow_tools(names: &[&str]) -> IndexMap { .collect() } +/// Return a deny-all rule for task execution. +/// +/// # Returns +/// A single-entry map keyed by [`task_meta::NAME`] with `PermissionRule::Action(Deny)`. +pub(crate) fn deny_task() -> IndexMap { + IndexMap::from([( + task_meta::NAME.into(), + PermissionRule::Action(PermissionAction::Deny), + )]) +} + /// Build task-scoped pattern permissions. /// /// Patterns are wrapped under the task metadata name so they apply to task execution. @@ -68,14 +79,3 @@ pub(crate) fn pattern_task( } IndexMap::from([(task_meta::NAME.into(), PermissionRule::Pattern(map))]) } - -/// Return a deny-all rule for task execution. -/// -/// # Returns -/// A single-entry map keyed by [`task_meta::NAME`] with `PermissionRule::Action(Deny)`. -pub(crate) fn deny_task() -> IndexMap { - IndexMap::from([( - task_meta::NAME.into(), - PermissionRule::Action(PermissionAction::Deny), - )]) -} diff --git a/src/reloaded-code-agents/src/types/config.rs b/src/reloaded-code-agents/src/types/config.rs index a0054282..f10f9630 100644 --- a/src/reloaded-code-agents/src/types/config.rs +++ b/src/reloaded-code-agents/src/types/config.rs @@ -46,62 +46,6 @@ use indexmap::IndexMap; use reloaded_code_core::permissions::PermissionAction; use serde::{Deserialize, Serialize}; -/// Agent execution mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgentMode { - /// Available in both contexts. - #[default] - All, - /// Can be selected as primary agent for conversations. - Primary, - /// Only available as subagent via Task tool. - Subagent, -} - -/// Permission rule: simple action or pattern-based map. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PermissionRule { - /// Simple allow/deny for all. - Action(PermissionAction), - /// Pattern-based rules (e.g., `{"orchestrator-*": "deny", "*": "allow"}`). - Pattern(IndexMap), -} - -impl Default for PermissionRule { - fn default() -> Self { - Self::Action(PermissionAction::default()) - } -} - -/// Raw frontmatter data (intermediate deserialization target). -#[derive(Debug, Clone, Deserialize)] -pub(crate) struct RawFrontmatter { - #[serde(default)] - pub name: Option>, - #[serde(default)] - pub mode: AgentMode, - pub description: Box, - #[serde(default)] - pub model: Option>, - /// Legacy visibility flag accepted for compatibility only. - /// - /// Runtime behaviour in headless mode ignores this field. - #[serde(default)] - pub hidden: bool, - #[serde(default)] - pub temperature: Option, - #[serde(default)] - pub top_p: Option, - #[serde(default)] - pub permission: IndexMap, - #[serde(default, deserialize_with = "deserialize_non_null_tool_settings")] - pub tool_settings: AgentToolSettings, - #[serde(default)] - pub options: AHashMap, -} - /// Agent configuration loaded from a markdown file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { @@ -149,6 +93,56 @@ pub struct AgentConfig { pub prompt: Box, } +/// Raw frontmatter data (intermediate deserialization target). +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct RawFrontmatter { + #[serde(default)] + pub name: Option>, + #[serde(default)] + pub mode: AgentMode, + pub description: Box, + #[serde(default)] + pub model: Option>, + /// Legacy visibility flag accepted for compatibility only. + /// + /// Runtime behaviour in headless mode ignores this field. + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub top_p: Option, + #[serde(default)] + pub permission: IndexMap, + #[serde(default, deserialize_with = "deserialize_non_null_tool_settings")] + pub tool_settings: AgentToolSettings, + #[serde(default)] + pub options: AHashMap, +} + +/// Agent execution mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentMode { + /// Available in both contexts. + #[default] + All, + /// Can be selected as primary agent for conversations. + Primary, + /// Only available as subagent via Task tool. + Subagent, +} + +/// Permission rule: simple action or pattern-based map. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PermissionRule { + /// Simple allow/deny for all. + Action(PermissionAction), + /// Pattern-based rules (e.g., `{"orchestrator-*": "deny", "*": "allow"}`). + Pattern(IndexMap), +} + impl AgentConfig { /// Returns the provider and model identifier from [`AgentConfig::model`]. /// @@ -191,6 +185,12 @@ impl AgentConfig { } } +impl Default for PermissionRule { + fn default() -> Self { + Self::Action(PermissionAction::default()) + } +} + /// Parses a model identifier string into `(provider, model)` parts. /// /// ## Expected Format diff --git a/src/reloaded-code-agents/src/types/error.rs b/src/reloaded-code-agents/src/types/error.rs index a3e77c5b..a19678e8 100644 --- a/src/reloaded-code-agents/src/types/error.rs +++ b/src/reloaded-code-agents/src/types/error.rs @@ -15,6 +15,9 @@ use crate::parser::AgentParseError; use std::fmt; use std::path::PathBuf; +/// Result type alias for agent configuration operations. +pub type AgentLoadResult = Result; + /// Error type for agent configuration operations. #[derive(Debug)] pub enum AgentLoadError { @@ -43,6 +46,26 @@ pub enum AgentLoadError { }, } +impl AgentLoadError { + /// Creates a new Io error. + pub fn io(path: Option, source: std::io::Error) -> Self { + Self::Io { path, source } + } + + /// Creates a new Parse error. + pub fn parse(path: Option, source: AgentParseError) -> Self { + Self::Parse { path, source } + } + + /// Creates a new SchemaValidation error. + pub fn schema_validation(path: Option, message: impl Into) -> Self { + Self::SchemaValidation { + path, + message: message.into(), + } + } +} + impl fmt::Display for AgentLoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -77,26 +100,3 @@ impl std::error::Error for AgentLoadError { } } } - -impl AgentLoadError { - /// Creates a new Io error. - pub fn io(path: Option, source: std::io::Error) -> Self { - Self::Io { path, source } - } - - /// Creates a new Parse error. - pub fn parse(path: Option, source: AgentParseError) -> Self { - Self::Parse { path, source } - } - - /// Creates a new SchemaValidation error. - pub fn schema_validation(path: Option, message: impl Into) -> Self { - Self::SchemaValidation { - path, - message: message.into(), - } - } -} - -/// Result type alias for agent configuration operations. -pub type AgentLoadResult = Result; diff --git a/src/reloaded-code-agents/src/types/mod.rs b/src/reloaded-code-agents/src/types/mod.rs index 8d2b8847..c569b92d 100644 --- a/src/reloaded-code-agents/src/types/mod.rs +++ b/src/reloaded-code-agents/src/types/mod.rs @@ -8,10 +8,7 @@ //! - Tool settings: [`AgentToolSettings`], [`ReadToolSettings`], [`GrepToolSettings`], //! [`GlobToolSettings`], [`BashToolSettings`], [`WebFetchToolSettings`] -mod config; -mod error; -mod tool_settings; - +pub(crate) use config::RawFrontmatter; pub use config::{parse_model_parts, AgentConfig, AgentMode, PermissionRule}; pub use error::{AgentLoadError, AgentLoadResult}; pub use tool_settings::{ @@ -19,4 +16,6 @@ pub use tool_settings::{ WebFetchToolSettings, }; -pub(crate) use config::RawFrontmatter; +mod config; +mod error; +mod tool_settings; diff --git a/src/reloaded-code-agents/src/types/tool_settings.rs b/src/reloaded-code-agents/src/types/tool_settings.rs index e0cedeee..6ca9e1f7 100644 --- a/src/reloaded-code-agents/src/types/tool_settings.rs +++ b/src/reloaded-code-agents/src/types/tool_settings.rs @@ -61,35 +61,34 @@ pub struct AgentToolSettings { pub webfetch: WebFetchToolSettings, } -/// Settings for the read tool. +/// Settings for the bash tool. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ReadToolSettings { - /// Whether to include line numbers in output (default: true). - #[serde(default = "default_line_numbers")] - pub line_numbers: bool, - /// Maximum lines to return per read (default: 2000, min: 1). +pub struct BashToolSettings { + /// Default timeout in milliseconds (default: 120000, min: 1000). #[serde( - default = "read_default_limit", - deserialize_with = "deserialize_min_limit" + default = "bash_default_timeout_ms", + deserialize_with = "deserialize_min_timeout_ms" )] - pub limit: usize, - /// Maximum characters per line before truncation (default: 2000, min: 4). + pub timeout_ms: u32, + /// Maximum timeout allowed for LLM requests (default: 600000, min: 1). #[serde( - default = "read_default_max_line_length", - deserialize_with = "deserialize_read_max_line_length" + default = "bash_default_max_timeout_ms", + deserialize_with = "deserialize_min_max_timeout_ms" )] - pub max_line_length: usize, + pub max_timeout_ms: u32, } -impl Default for ReadToolSettings { - fn default() -> Self { - Self { - line_numbers: true, - limit: read_default_limit(), - max_line_length: read_default_max_line_length(), - } - } +/// Settings for the glob tool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GlobToolSettings { + /// Maximum files to return (default: 1000, min: 1). + #[serde( + default = "glob_default_limit", + deserialize_with = "deserialize_min_limit" + )] + pub limit: usize, } /// Settings for the grep tool. @@ -113,61 +112,25 @@ pub struct GrepToolSettings { pub max_line_length: usize, } -impl Default for GrepToolSettings { - fn default() -> Self { - Self { - line_numbers: true, - limit: grep_default_limit(), - max_line_length: grep_default_max_line_length(), - } - } -} - -/// Settings for the glob tool. +/// Settings for the read tool. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct GlobToolSettings { - /// Maximum files to return (default: 1000, min: 1). +pub struct ReadToolSettings { + /// Whether to include line numbers in output (default: true). + #[serde(default = "default_line_numbers")] + pub line_numbers: bool, + /// Maximum lines to return per read (default: 2000, min: 1). #[serde( - default = "glob_default_limit", + default = "read_default_limit", deserialize_with = "deserialize_min_limit" )] pub limit: usize, -} - -impl Default for GlobToolSettings { - fn default() -> Self { - Self { - limit: glob_default_limit(), - } - } -} - -/// Settings for the bash tool. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BashToolSettings { - /// Default timeout in milliseconds (default: 120000, min: 1000). - #[serde( - default = "bash_default_timeout_ms", - deserialize_with = "deserialize_min_timeout_ms" - )] - pub timeout_ms: u32, - /// Maximum timeout allowed for LLM requests (default: 600000, min: 1). + /// Maximum characters per line before truncation (default: 2000, min: 4). #[serde( - default = "bash_default_max_timeout_ms", - deserialize_with = "deserialize_min_max_timeout_ms" + default = "read_default_max_line_length", + deserialize_with = "deserialize_read_max_line_length" )] - pub max_timeout_ms: u32, -} - -impl Default for BashToolSettings { - fn default() -> Self { - Self { - timeout_ms: bash_default_timeout_ms(), - max_timeout_ms: bash_default_max_timeout_ms(), - } - } + pub max_line_length: usize, } /// Settings for the webfetch tool. @@ -194,50 +157,76 @@ pub struct WebFetchToolSettings { pub max_response_size: usize, } -impl Default for WebFetchToolSettings { +impl AgentToolSettings { + /// Validates cross-field constraints for bash timeout pair. + /// Note: read/glob/grep/webfetch validation now happens during agent build + /// when these values are converted into Core settings. + #[inline] + fn validate(&self) -> Result<(), String> { + validate_timeout_pair("bash", self.bash.timeout_ms, self.bash.max_timeout_ms) + } +} + +impl Default for BashToolSettings { fn default() -> Self { Self { - timeout_ms: webfetch_default_timeout_ms(), - max_timeout_ms: webfetch_default_max_timeout_ms(), - max_response_size: webfetch_default_max_response_size(), + timeout_ms: bash_default_timeout_ms(), + max_timeout_ms: bash_default_max_timeout_ms(), } } } -#[inline] -const fn default_line_numbers() -> bool { - true -} - -#[inline] -const fn read_default_limit() -> usize { - read::DEFAULT_LIMIT +impl Default for GlobToolSettings { + fn default() -> Self { + Self { + limit: glob_default_limit(), + } + } } -#[inline] -const fn read_default_max_line_length() -> usize { - read::MAX_LINE_LENGTH +impl Default for GrepToolSettings { + fn default() -> Self { + Self { + line_numbers: true, + limit: grep_default_limit(), + max_line_length: grep_default_max_line_length(), + } + } } -#[inline] -const fn grep_default_limit() -> usize { - grep::DEFAULT_LIMIT +impl Default for ReadToolSettings { + fn default() -> Self { + Self { + line_numbers: true, + limit: read_default_limit(), + max_line_length: read_default_max_line_length(), + } + } } -#[inline] -const fn grep_default_max_line_length() -> usize { - // Grep uses the same max line length as read - read::MAX_LINE_LENGTH +impl Default for WebFetchToolSettings { + fn default() -> Self { + Self { + timeout_ms: webfetch_default_timeout_ms(), + max_timeout_ms: webfetch_default_max_timeout_ms(), + max_response_size: webfetch_default_max_response_size(), + } + } } -#[inline] -const fn glob_default_limit() -> usize { - glob::MAX_RESULTS -} +/// Deserializes `tool_settings`, rejecting explicit `null` while allowing +/// absence to default. +pub(crate) fn deserialize_non_null_tool_settings<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)? + .ok_or_else(|| serde::de::Error::custom("tool_settings cannot be null"))?; -#[inline] -const fn bash_default_timeout_ms() -> u32 { - bash::DEFAULT_TIMEOUT_MS + value.validate().map_err(serde::de::Error::custom)?; + Ok(value) } #[inline] @@ -246,25 +235,13 @@ const fn bash_default_max_timeout_ms() -> u32 { } #[inline] -const fn webfetch_default_timeout_ms() -> u32 { - webfetch::DEFAULT_TIMEOUT_MS -} - -#[inline] -const fn webfetch_default_max_timeout_ms() -> u32 { - webfetch::MAX_TIMEOUT_MS +const fn bash_default_timeout_ms() -> u32 { + bash::DEFAULT_TIMEOUT_MS } #[inline] -const fn webfetch_default_max_response_size() -> usize { - webfetch::MAX_RESPONSE_SIZE -} - -fn deserialize_read_max_line_length<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - deserialize_min_max_line_length(deserializer, "read.max_line_length") +const fn default_line_numbers() -> bool { + true } fn deserialize_grep_max_line_length<'de, D>(deserializer: D) -> Result @@ -274,33 +251,30 @@ where deserialize_min_max_line_length(deserializer, "grep.max_line_length") } -fn deserialize_min_max_line_length<'de, D>( - deserializer: D, - field_name: &str, -) -> Result +fn deserialize_min_limit<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = usize::deserialize(deserializer)?; - if value < MIN_LINE_LENGTH { + if value < MIN_LIMIT { return Err(serde::de::Error::custom(format!( - "{field_name} must be >= {}", - MIN_LINE_LENGTH + "value must be >= {}", + MIN_LIMIT ))); } Ok(value) } -fn deserialize_min_limit<'de, D>(deserializer: D) -> Result +/// Deserializes max_timeout_ms ensuring it's at least 1. +fn deserialize_min_max_timeout_ms<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - let value = usize::deserialize(deserializer)?; - if value < MIN_LIMIT { - return Err(serde::de::Error::custom(format!( - "value must be >= {}", - MIN_LIMIT - ))); + let value = u32::deserialize(deserializer)?; + if value == 0 { + return Err(serde::de::Error::custom( + "max_timeout_ms must be at least 1", + )); } Ok(value) } @@ -319,43 +293,37 @@ where Ok(value) } -/// Deserializes max_timeout_ms ensuring it's at least 1. -fn deserialize_min_max_timeout_ms<'de, D>(deserializer: D) -> Result +fn deserialize_read_max_line_length<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - let value = u32::deserialize(deserializer)?; - if value == 0 { - return Err(serde::de::Error::custom( - "max_timeout_ms must be at least 1", - )); - } - Ok(value) + deserialize_min_max_line_length(deserializer, "read.max_line_length") } -/// Deserializes `tool_settings`, rejecting explicit `null` while allowing -/// absence to default. -pub(crate) fn deserialize_non_null_tool_settings<'de, D>( - deserializer: D, -) -> Result -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)? - .ok_or_else(|| serde::de::Error::custom("tool_settings cannot be null"))?; +#[inline] +const fn glob_default_limit() -> usize { + glob::MAX_RESULTS +} - value.validate().map_err(serde::de::Error::custom)?; - Ok(value) +#[inline] +const fn grep_default_limit() -> usize { + grep::DEFAULT_LIMIT } -impl AgentToolSettings { - /// Validates cross-field constraints for bash timeout pair. - /// Note: read/glob/grep/webfetch validation now happens during agent build - /// when these values are converted into Core settings. - #[inline] - fn validate(&self) -> Result<(), String> { - validate_timeout_pair("bash", self.bash.timeout_ms, self.bash.max_timeout_ms) - } +#[inline] +const fn grep_default_max_line_length() -> usize { + // Grep uses the same max line length as read + read::MAX_LINE_LENGTH +} + +#[inline] +const fn read_default_limit() -> usize { + read::DEFAULT_LIMIT +} + +#[inline] +const fn read_default_max_line_length() -> usize { + read::MAX_LINE_LENGTH } #[inline] @@ -367,3 +335,35 @@ fn validate_timeout_pair(tool: &str, timeout_ms: u32, max_timeout_ms: u32) -> Re } Ok(()) } + +#[inline] +const fn webfetch_default_max_response_size() -> usize { + webfetch::MAX_RESPONSE_SIZE +} + +#[inline] +const fn webfetch_default_max_timeout_ms() -> u32 { + webfetch::MAX_TIMEOUT_MS +} + +#[inline] +const fn webfetch_default_timeout_ms() -> u32 { + webfetch::DEFAULT_TIMEOUT_MS +} + +fn deserialize_min_max_line_length<'de, D>( + deserializer: D, + field_name: &str, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = usize::deserialize(deserializer)?; + if value < MIN_LINE_LENGTH { + return Err(serde::de::Error::custom(format!( + "{field_name} must be >= {}", + MIN_LINE_LENGTH + ))); + } + Ok(value) +} diff --git a/src/reloaded-code-bubblewrap/src/lib.rs b/src/reloaded-code-bubblewrap/src/lib.rs index c88d6688..88120410 100644 --- a/src/reloaded-code-bubblewrap/src/lib.rs +++ b/src/reloaded-code-bubblewrap/src/lib.rs @@ -3,15 +3,6 @@ #[cfg(not(target_os = "linux"))] compile_error!("reloaded-code-bubblewrap is only supported on Linux"); -mod error; -mod path_util; -mod probe; -pub mod profile; -pub mod wrap; - -#[cfg(test)] -mod test_helpers; - pub use error::LinuxBwrapError; pub use profile::{ create_sandbox, create_sandbox_with, create_temp_sandbox, CreateSandboxError, SandboxDirs, @@ -21,3 +12,11 @@ pub use profile::{ Availability, Builder, EnvVar, FileMount, NetworkPolicy, Preset, Profile, Symlink, TmpBacking, }; pub use wrap::LinuxBwrapWrappedCommand; + +mod error; +mod path_util; +mod probe; +pub mod profile; +#[cfg(test)] +mod test_helpers; +pub mod wrap; diff --git a/src/reloaded-code-bubblewrap/src/probe.rs b/src/reloaded-code-bubblewrap/src/probe.rs index 6097cfa1..1b5d1e58 100644 --- a/src/reloaded-code-bubblewrap/src/probe.rs +++ b/src/reloaded-code-bubblewrap/src/probe.rs @@ -13,10 +13,10 @@ use std::process::{Command, Output, Stdio}; use std::sync::Arc; use std::sync::OnceLock; -/// A no-op shell command used as the probe payload. -const PROBE_COMMAND: &str = ":"; /// Sentinel argument appended to the probe command to distinguish its logs. pub(crate) const PROBE_ARG0: &str = "__reloaded_code_bwrap_probe__"; +/// A no-op shell command used as the probe payload. +const PROBE_COMMAND: &str = ":"; /// Absolute paths checked when `PATH` lookups for `bash`/`sh` yield nothing. const SHELL_CANDIDATES: &[&str] = &[ "/run/current-system/sw/bin/bash", @@ -40,6 +40,18 @@ enum LinuxBwrapBackend { Unusable { reason: Box }, } +/// Returns the first shell binary for which `classify` returns [`Some`], +/// checking `PATH` first then the hardcoded [`SHELL_CANDIDATES`]. +/// +/// On success the host path and the classifier's return value are yielded +/// together so the caller need not re-classify. +pub(crate) fn first_shell_candidate_with(mut classify: F) -> Option<(Box, R)> +where + F: FnMut(&Path) -> Option, +{ + first_shell_candidate_with_in(env::var_os("PATH").as_deref(), &mut classify) +} + /// Returns whether `bwrap` is usable on this host. /// /// Results are cached per `PATH` value within the process lifetime. @@ -91,93 +103,6 @@ pub(crate) fn resolve_backend_or_error_for( } } -fn profile_name(preset: Option) -> &'static str { - match preset { - Some(Preset::PublicBot) => "PublicBot", - Some(Preset::TrustedMaintenance) => "TrustedMaintenance", - None => "Custom", - } -} - -#[inline] -fn find_binary_on_path_in(name: &str, path: Option<&OsStr>) -> Option> { - let path = path?; - for dir in env::split_paths(path) { - if !dir.is_absolute() || dir.as_os_str().is_empty() { - continue; - } - let candidate = dir.join(name); - if candidate.is_file() { - return Some(candidate.into_boxed_path()); - } - } - None -} - -/// Returns the first shell binary for which `classify` returns [`Some`], -/// checking `PATH` first then the hardcoded [`SHELL_CANDIDATES`]. -/// -/// On success the host path and the classifier's return value are yielded -/// together so the caller need not re-classify. -pub(crate) fn first_shell_candidate_with(mut classify: F) -> Option<(Box, R)> -where - F: FnMut(&Path) -> Option, -{ - first_shell_candidate_with_in(env::var_os("PATH").as_deref(), &mut classify) -} - -fn first_shell_candidate_with_in( - env_path: Option<&OsStr>, - classify: &mut F, -) -> Option<(Box, R)> -where - F: FnMut(&Path) -> Option, -{ - let mut seen = HashSet::with_capacity(10); - - for name in ["bash", "sh"] { - if let Some(shell_path) = find_binary_on_path_in(name, env_path) { - if let Some(result) = classify_shell_candidate(classify, &mut seen, shell_path) { - return Some(result); - } - } - } - - for candidate in SHELL_CANDIDATES { - let candidate_path = PathBuf::from(candidate); - if candidate_path.is_file() { - if let Some(result) = - classify_shell_candidate(classify, &mut seen, candidate_path.into_boxed_path()) - { - return Some(result); - } - } - } - - None -} - -#[inline] -fn classify_shell_candidate( - classify: &mut F, - seen: &mut HashSet>, - path: Box, -) -> Option<(Box, R)> -where - F: FnMut(&Path) -> Option, -{ - let path = normalize_path(path.as_ref()); - if !seen.insert(path.clone()) { - return None; - } - classify(path.as_ref()).map(|result| (path, result)) -} - -#[inline] -fn resolve_host_shell_in(path: Option<&OsStr>) -> Option> { - first_shell_candidate_with_in(path, &mut |_| Some(())).map(|(path, _)| path) -} - fn probe_backend() -> LinuxBwrapBackend { // Cache keyed on PATH: a changed PATH invalidates the result. #[allow(clippy::type_complexity)] @@ -200,6 +125,14 @@ fn probe_backend() -> LinuxBwrapBackend { backend } +fn profile_name(preset: Option) -> &'static str { + match preset { + Some(Preset::PublicBot) => "PublicBot", + Some(Preset::TrustedMaintenance) => "TrustedMaintenance", + None => "Custom", + } +} + /// Checks `bwrap` without using the cache. /// /// The probe binds the host root read-only and runs a tiny shell command. That @@ -270,6 +203,73 @@ fn probe_failure_reason(output: &Output, fallback: &str) -> Box { } } +#[inline] +fn resolve_host_shell_in(path: Option<&OsStr>) -> Option> { + first_shell_candidate_with_in(path, &mut |_| Some(())).map(|(path, _)| path) +} + +fn first_shell_candidate_with_in( + env_path: Option<&OsStr>, + classify: &mut F, +) -> Option<(Box, R)> +where + F: FnMut(&Path) -> Option, +{ + let mut seen = HashSet::with_capacity(10); + + for name in ["bash", "sh"] { + if let Some(shell_path) = find_binary_on_path_in(name, env_path) { + if let Some(result) = classify_shell_candidate(classify, &mut seen, shell_path) { + return Some(result); + } + } + } + + for candidate in SHELL_CANDIDATES { + let candidate_path = PathBuf::from(candidate); + if candidate_path.is_file() { + if let Some(result) = + classify_shell_candidate(classify, &mut seen, candidate_path.into_boxed_path()) + { + return Some(result); + } + } + } + + None +} + +#[inline] +fn classify_shell_candidate( + classify: &mut F, + seen: &mut HashSet>, + path: Box, +) -> Option<(Box, R)> +where + F: FnMut(&Path) -> Option, +{ + let path = normalize_path(path.as_ref()); + if !seen.insert(path.clone()) { + return None; + } + classify(path.as_ref()).map(|result| (path, result)) +} + +#[inline] +fn find_binary_on_path_in(name: &str, path: Option<&OsStr>) -> Option> { + let path = path?; + for dir in env::split_paths(path) { + if !dir.is_absolute() || dir.as_os_str().is_empty() { + continue; + } + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate.into_boxed_path()); + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-bubblewrap/src/profile/builder.rs b/src/reloaded-code-bubblewrap/src/profile/builder.rs index a9401a69..77ade6fd 100644 --- a/src/reloaded-code-bubblewrap/src/profile/builder.rs +++ b/src/reloaded-code-bubblewrap/src/profile/builder.rs @@ -106,11 +106,14 @@ pub struct Builder { pub(crate) read_only_host_rootfs: bool, /// Controls whether the sandbox has network access. pub(crate) network_policy: NetworkPolicy, - /// When `true`, inherited env vars are cleared before applying [`default_env`](Self::default_env) and [`extra_env`](Self::extra_env). + /// When `true`, inherited env vars are cleared before applying [`default_env`] and [`extra_env`]. + /// + /// [`default_env`]: Self::default_env + /// [`extra_env`]: Self::extra_env pub(crate) clear_env: bool, - /// Env vars always set (applied before [`extra_env`](Self::extra_env)). + /// Env vars always set (applied before [`extra_env`]). pub(crate) default_env: Arc<[EnvVar]>, - /// Additional env vars set on top of [`default_env`](Self::default_env). + /// Additional env vars set on top of [`default_env`]. pub(crate) extra_env: Arc<[EnvVar]>, /// Tracks whether `bwrap` is usable (checked during [`build`](Self::build)). pub(crate) availability: Availability, @@ -332,105 +335,6 @@ impl Builder { } } -fn validate_builder(builder: &Builder) -> Result<(), LinuxBwrapError> { - validate_directory_path(builder.workspace.as_ref(), "workspace host directory")?; - validate_directory_path( - builder.synthetic_home.as_ref(), - "synthetic home host directory", - )?; - validate_absolute_path(builder.cache_root.as_ref(), "cache root host path")?; - if builder.mount_cache_root { - validate_directory_path(builder.cache_root.as_ref(), "cache root host directory")?; - } - - validate_absolute_path(builder.workspace_dest.as_ref(), "workspace destination")?; - validate_absolute_path( - builder.synthetic_home_dest.as_ref(), - "synthetic home destination", - )?; - validate_tmp_backing(&builder.tmp_backing)?; - validate_mount_paths(&builder.read_only_mounts, "read-only mount source")?; - validate_mount_paths(&builder.read_write_mounts, "read-write mount source")?; - validate_tmpfs_overlays(&builder.tmpfs_overlays)?; - validate_file_overlays(&builder.file_overlays)?; - validate_symlinks(&builder.compat_symlinks)?; - validate_env_vars(builder.default_env.as_ref(), "default")?; - validate_env_vars(builder.extra_env.as_ref(), "extra")?; - validate_credential_file_mounts(builder)?; - Ok(()) -} - -fn validate_credential_file_mounts(builder: &Builder) -> Result<(), LinuxBwrapError> { - for mount in builder.credential_file_mounts.iter() { - validate_absolute_path(mount.source(), "credential file source")?; - validate_absolute_path(mount.dest(), "credential file destination")?; - - let metadata = fs::metadata(mount.source()).map_err(|error| { - LinuxBwrapError::InvalidPath(format!( - "credential file source must exist and be readable: {} ({error})", - mount.source().display() - )) - })?; - if !metadata.is_file() { - return Err(LinuxBwrapError::InvalidPath(format!( - "credential file source must be a regular file: {}", - mount.source().display() - ))); - } - if !credential_dest_is_allowed(builder, mount.dest()) { - return Err(LinuxBwrapError::InvalidPath(format!( - "credential file destination must stay within the synthetic home, workspace, or cache root: {}", - mount.dest().display() - ))); - } - } - - Ok(()) -} - -fn credential_dest_is_allowed(builder: &Builder, dest: &Path) -> bool { - dest.starts_with(builder.synthetic_home_dest.as_ref()) - || dest.starts_with(builder.workspace_dest.as_ref()) - || (builder.mount_cache_root && dest.starts_with(builder.cache_root.as_ref())) -} - -fn resolve_shell_for_builder(builder: &Builder) -> Result, LinuxBwrapError> { - let layout = builder_sandbox_layout(builder); - if let Some((_host_shell, sandbox_path)) = first_shell_candidate_with(|shell| { - layout.classify(shell).map(|mapping| match mapping { - PathMapping::SamePath => shell.to_path_buf(), - PathMapping::Remap { - dest_prefix, - relative, - } => join_mapped_path(dest_prefix, relative).into_owned(), - }) - }) { - return Ok(sandbox_path.into_boxed_path()); - } - - Err(LinuxBwrapError::Execution( - "no usable host shell is visible inside the linux sandbox; expected a system `bash` or `sh` mounted by the selected profile" - .to_string(), - )) -} - -fn builder_sandbox_layout(builder: &Builder) -> SandboxLayout<'_> { - SandboxLayout { - workspace: builder.workspace.as_ref(), - workspace_dest: builder.workspace_dest.as_ref(), - synthetic_home: builder.synthetic_home.as_ref(), - synthetic_home_dest: builder.synthetic_home_dest.as_ref(), - cache_root: builder.cache_root.as_ref(), - mount_cache_root: builder.mount_cache_root, - tmp_backing: &builder.tmp_backing, - read_only_host_rootfs: builder.read_only_host_rootfs, - tmpfs_overlays: builder.tmpfs_overlays.as_ref(), - file_overlays: builder.file_overlays.as_ref(), - read_only_mounts: builder.read_only_mounts.as_ref(), - read_write_mounts: builder.read_write_mounts.as_ref(), - } -} - fn build_static_args(builder: &Builder) -> Arc<[OsString]> { let mut args = Vec::with_capacity(arg_capacity_for(builder)); @@ -490,6 +394,54 @@ fn build_static_args(builder: &Builder) -> Arc<[OsString]> { Arc::from(args) } +fn resolve_shell_for_builder(builder: &Builder) -> Result, LinuxBwrapError> { + let layout = builder_sandbox_layout(builder); + if let Some((_host_shell, sandbox_path)) = first_shell_candidate_with(|shell| { + layout.classify(shell).map(|mapping| match mapping { + PathMapping::SamePath => shell.to_path_buf(), + PathMapping::Remap { + dest_prefix, + relative, + } => join_mapped_path(dest_prefix, relative).into_owned(), + }) + }) { + return Ok(sandbox_path.into_boxed_path()); + } + + Err(LinuxBwrapError::Execution( + "no usable host shell is visible inside the linux sandbox; expected a system `bash` or `sh` mounted by the selected profile" + .to_string(), + )) +} + +fn validate_builder(builder: &Builder) -> Result<(), LinuxBwrapError> { + validate_directory_path(builder.workspace.as_ref(), "workspace host directory")?; + validate_directory_path( + builder.synthetic_home.as_ref(), + "synthetic home host directory", + )?; + validate_absolute_path(builder.cache_root.as_ref(), "cache root host path")?; + if builder.mount_cache_root { + validate_directory_path(builder.cache_root.as_ref(), "cache root host directory")?; + } + + validate_absolute_path(builder.workspace_dest.as_ref(), "workspace destination")?; + validate_absolute_path( + builder.synthetic_home_dest.as_ref(), + "synthetic home destination", + )?; + validate_tmp_backing(&builder.tmp_backing)?; + validate_mount_paths(&builder.read_only_mounts, "read-only mount source")?; + validate_mount_paths(&builder.read_write_mounts, "read-write mount source")?; + validate_tmpfs_overlays(&builder.tmpfs_overlays)?; + validate_file_overlays(&builder.file_overlays)?; + validate_symlinks(&builder.compat_symlinks)?; + validate_env_vars(builder.default_env.as_ref(), "default")?; + validate_env_vars(builder.extra_env.as_ref(), "extra")?; + validate_credential_file_mounts(builder)?; + Ok(()) +} + fn arg_capacity_for(builder: &Builder) -> usize { let env_count = builder.default_env.len() + builder.extra_env.len(); let ro_slots = if builder.read_only_host_rootfs { @@ -515,17 +467,20 @@ fn arg_capacity_for(builder: &Builder) -> usize { fixed_slots + env_count * 3 + mount_slots + tmp_slots } -fn push_bind(args: &mut Vec, flag: &str, source: &Path, dest: &Path) { - args.push(OsString::from(flag)); - args.push(source.as_os_str().into()); - args.push(dest.as_os_str().into()); -} - -fn push_symlinks(args: &mut Vec, symlinks: &[Symlink]) { - for symlink in symlinks { - args.push(OsString::from("--symlink")); - args.push(OsString::from(symlink.target())); - args.push(symlink.link_path().as_os_str().into()); +fn builder_sandbox_layout(builder: &Builder) -> SandboxLayout<'_> { + SandboxLayout { + workspace: builder.workspace.as_ref(), + workspace_dest: builder.workspace_dest.as_ref(), + synthetic_home: builder.synthetic_home.as_ref(), + synthetic_home_dest: builder.synthetic_home_dest.as_ref(), + cache_root: builder.cache_root.as_ref(), + mount_cache_root: builder.mount_cache_root, + tmp_backing: &builder.tmp_backing, + read_only_host_rootfs: builder.read_only_host_rootfs, + tmpfs_overlays: builder.tmpfs_overlays.as_ref(), + file_overlays: builder.file_overlays.as_ref(), + read_only_mounts: builder.read_only_mounts.as_ref(), + read_write_mounts: builder.read_write_mounts.as_ref(), } } @@ -537,28 +492,29 @@ fn push_env_args(args: &mut Vec, env_vars: &[EnvVar]) { } } -fn push_same_path_bind(args: &mut Vec, flag: &str, path: &Path) { - args.push(OsString::from(flag)); - args.push(path.as_os_str().into()); - args.push(path.as_os_str().into()); +fn push_file_mounts(args: &mut Vec, mounts: &[FileMount]) { + for mount in mounts { + push_bind(args, "--ro-bind", mount.source(), mount.dest()); + } } -fn push_same_path_binds(args: &mut Vec, flag: &str, paths: &[Box]) { - for path in paths { - push_same_path_bind(args, flag, path); +fn push_file_overlay_mounts(args: &mut Vec, overlays: &[FileOverlay]) { + for overlay in overlays { + push_bind(args, "--ro-bind", overlay.source(), overlay.dest()); } } -fn push_tmpfs_mounts(args: &mut Vec, paths: &[Box]) { +fn push_same_path_binds(args: &mut Vec, flag: &str, paths: &[Box]) { for path in paths { - args.push(OsString::from("--tmpfs")); - args.push(path.as_os_str().into()); + push_same_path_bind(args, flag, path); } } -fn push_file_overlay_mounts(args: &mut Vec, overlays: &[FileOverlay]) { - for overlay in overlays { - push_bind(args, "--ro-bind", overlay.source(), overlay.dest()); +fn push_symlinks(args: &mut Vec, symlinks: &[Symlink]) { + for symlink in symlinks { + args.push(OsString::from("--symlink")); + args.push(OsString::from(symlink.target())); + args.push(symlink.link_path().as_os_str().into()); } } @@ -572,12 +528,59 @@ fn push_tmp_mount(args: &mut Vec, tmp_backing: &TmpBacking) { } } -fn push_file_mounts(args: &mut Vec, mounts: &[FileMount]) { - for mount in mounts { - push_bind(args, "--ro-bind", mount.source(), mount.dest()); +fn push_tmpfs_mounts(args: &mut Vec, paths: &[Box]) { + for path in paths { + args.push(OsString::from("--tmpfs")); + args.push(path.as_os_str().into()); } } +fn validate_credential_file_mounts(builder: &Builder) -> Result<(), LinuxBwrapError> { + for mount in builder.credential_file_mounts.iter() { + validate_absolute_path(mount.source(), "credential file source")?; + validate_absolute_path(mount.dest(), "credential file destination")?; + + let metadata = fs::metadata(mount.source()).map_err(|error| { + LinuxBwrapError::InvalidPath(format!( + "credential file source must exist and be readable: {} ({error})", + mount.source().display() + )) + })?; + if !metadata.is_file() { + return Err(LinuxBwrapError::InvalidPath(format!( + "credential file source must be a regular file: {}", + mount.source().display() + ))); + } + if !credential_dest_is_allowed(builder, mount.dest()) { + return Err(LinuxBwrapError::InvalidPath(format!( + "credential file destination must stay within the synthetic home, workspace, or cache root: {}", + mount.dest().display() + ))); + } + } + + Ok(()) +} + +fn credential_dest_is_allowed(builder: &Builder, dest: &Path) -> bool { + dest.starts_with(builder.synthetic_home_dest.as_ref()) + || dest.starts_with(builder.workspace_dest.as_ref()) + || (builder.mount_cache_root && dest.starts_with(builder.cache_root.as_ref())) +} + +fn push_bind(args: &mut Vec, flag: &str, source: &Path, dest: &Path) { + args.push(OsString::from(flag)); + args.push(source.as_os_str().into()); + args.push(dest.as_os_str().into()); +} + +fn push_same_path_bind(args: &mut Vec, flag: &str, path: &Path) { + args.push(OsString::from(flag)); + args.push(path.as_os_str().into()); + args.push(path.as_os_str().into()); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-bubblewrap/src/profile/factory.rs b/src/reloaded-code-bubblewrap/src/profile/factory.rs index ef51092d..ee8ab33a 100644 --- a/src/reloaded-code-bubblewrap/src/profile/factory.rs +++ b/src/reloaded-code-bubblewrap/src/profile/factory.rs @@ -18,6 +18,20 @@ use crate::LinuxBwrapError; use std::path::Path; use std::sync::Arc; +/// Errors that can occur while creating a sandbox profile. +#[derive(Debug, thiserror::Error)] +pub enum CreateSandboxError { + /// Failed to create the sandbox directory layout. + #[error("failed to create sandbox directories: {0}")] + Dirs(#[source] std::io::Error), + /// Bubblewrap is not available on the host. + #[error("bubblewrap is not available: {0}")] + Unavailable(String), + /// Profile validation or assembly failed. + #[error("profile validation failed: {0}")] + Profile(#[from] LinuxBwrapError), +} + /// Borrowed directory paths for sandbox construction. /// /// Lightweight view over three host directories that a sandbox profile @@ -50,6 +64,21 @@ pub struct SandboxDirs<'a> { host_tmp: &'a Path, } +/// Auto-managed temp directory layout for sandbox construction. +/// +/// Creates a temp directory with `home`, `cache`, and `host-tmp` +/// subdirectories. The `cache` subdirectory also gets `xdg-cache` and +/// `xdg-state` sub-subdirectories created by [`Builder::build`]. +/// +/// Wrapped in [`Arc`] when returned from [`create_temp_sandbox`] so it can +/// be stored alongside the profile in shared state. +pub struct TempSandboxDirs { + tmpdir: tempfile::TempDir, + home: Box, + cache: Box, + host_tmp: Box, +} + impl<'a> SandboxDirs<'a> { /// Creates a new directory spec from borrowed host paths. /// @@ -81,21 +110,6 @@ impl<'a> SandboxDirs<'a> { } } -/// Auto-managed temp directory layout for sandbox construction. -/// -/// Creates a temp directory with `home`, `cache`, and `host-tmp` -/// subdirectories. The `cache` subdirectory also gets `xdg-cache` and -/// `xdg-state` sub-subdirectories created by [`Builder::build`]. -/// -/// Wrapped in [`Arc`] when returned from [`create_temp_sandbox`] so it can -/// be stored alongside the profile in shared state. -pub struct TempSandboxDirs { - tmpdir: tempfile::TempDir, - home: Box, - cache: Box, - host_tmp: Box, -} - impl TempSandboxDirs { /// Creates a new temp directory layout. /// @@ -159,20 +173,6 @@ impl TempSandboxDirs { } } -/// Errors that can occur while creating a sandbox profile. -#[derive(Debug, thiserror::Error)] -pub enum CreateSandboxError { - /// Failed to create the sandbox directory layout. - #[error("failed to create sandbox directories: {0}")] - Dirs(#[source] std::io::Error), - /// Bubblewrap is not available on the host. - #[error("bubblewrap is not available: {0}")] - Unavailable(String), - /// Profile validation or assembly failed. - #[error("profile validation failed: {0}")] - Profile(#[from] LinuxBwrapError), -} - /// Creates a sandbox from a preset and a directory spec. /// /// # Arguments @@ -280,6 +280,17 @@ pub fn create_temp_sandbox( Ok((profile, Arc::new(dirs))) } +fn create_sandbox_inner( + builder: Builder, + availability: Availability, +) -> Result, CreateSandboxError> { + let profile = builder + .with_availability(availability) + .build() + .map_err(CreateSandboxError::Profile)?; + Ok(Arc::new(profile)) +} + // Check if bwrap is available on the host. fn detect_availability() -> Result { let availability = Availability::detect(); @@ -294,17 +305,6 @@ fn detect_availability() -> Result { Ok(availability) } -fn create_sandbox_inner( - builder: Builder, - availability: Availability, -) -> Result, CreateSandboxError> { - let profile = builder - .with_availability(availability) - .build() - .map_err(CreateSandboxError::Profile)?; - Ok(Arc::new(profile)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-bubblewrap/src/profile/layout.rs b/src/reloaded-code-bubblewrap/src/profile/layout.rs index 535efa3a..fc789fca 100644 --- a/src/reloaded-code-bubblewrap/src/profile/layout.rs +++ b/src/reloaded-code-bubblewrap/src/profile/layout.rs @@ -11,6 +11,19 @@ use super::types::{FileOverlay, TmpBacking}; use std::borrow::Cow; use std::path::{Path, PathBuf}; +/// Describes where a host path ends up inside the sandbox. +pub(crate) enum PathMapping<'config, 'path> { + /// The path appears at the same absolute location in the sandbox. + SamePath, + /// The path appears under a different prefix inside the sandbox. + /// + /// The sandbox path is `dest_prefix` joined with `relative`. + Remap { + dest_prefix: &'config Path, + relative: &'path Path, + }, +} + /// Snapshot of the path-mapping rules that determine which host paths are /// reachable inside the sandbox and where they appear. /// @@ -33,19 +46,6 @@ pub(crate) struct SandboxLayout<'a> { pub(crate) read_write_mounts: &'a [Box], } -/// Describes where a host path ends up inside the sandbox. -pub(crate) enum PathMapping<'config, 'path> { - /// The path appears at the same absolute location in the sandbox. - SamePath, - /// The path appears under a different prefix inside the sandbox. - /// - /// The sandbox path is `dest_prefix` joined with `relative`. - Remap { - dest_prefix: &'config Path, - relative: &'path Path, - }, -} - impl<'config> SandboxLayout<'config> { /// Determines how `entry` appears inside the sandbox, if at all. /// @@ -102,6 +102,19 @@ impl<'config> SandboxLayout<'config> { } } +/// Maps a sandbox prefix and relative path into a sandbox path. +pub(crate) fn join_mapped_path<'a>(base: &'a Path, relative: &Path) -> Cow<'a, Path> { + if relative.as_os_str().is_empty() { + Cow::Borrowed(base) + } else { + let mut joined = + PathBuf::with_capacity(base.as_os_str().len() + relative.as_os_str().len() + 1); + joined.push(base); + joined.push(relative); + Cow::Owned(joined) + } +} + /// Whether `entry` is masked by a tmpfs overlay (and therefore unreadable /// even when the host rootfs is mounted read-only). /// @@ -140,19 +153,6 @@ pub(crate) fn path_hidden_by_overlay( } } -/// Maps a sandbox prefix and relative path into a sandbox path. -pub(crate) fn join_mapped_path<'a>(base: &'a Path, relative: &Path) -> Cow<'a, Path> { - if relative.as_os_str().is_empty() { - Cow::Borrowed(base) - } else { - let mut joined = - PathBuf::with_capacity(base.as_os_str().len() + relative.as_os_str().len() + 1); - joined.push(base); - joined.push(relative); - Cow::Owned(joined) - } -} - fn map_prefix<'config, 'path>( entry: &'path Path, host_prefix: &Path, diff --git a/src/reloaded-code-bubblewrap/src/profile/mod.rs b/src/reloaded-code-bubblewrap/src/profile/mod.rs index 105c3fd4..48012f06 100644 --- a/src/reloaded-code-bubblewrap/src/profile/mod.rs +++ b/src/reloaded-code-bubblewrap/src/profile/mod.rs @@ -6,13 +6,6 @@ //! - [`Preset`] - preset name stored on the profile //! - [`TmpBacking`] - how sandbox `/tmp` is mounted -mod builder; -mod factory; -pub(crate) mod layout; -mod presets; -mod types; -pub(crate) mod validation; - pub use builder::Builder; pub use factory::{ create_sandbox, create_sandbox_with, create_temp_sandbox, CreateSandboxError, SandboxDirs, @@ -22,3 +15,10 @@ pub use types::{ Availability, EnvVar, FileMount, FileOverlay, NetworkPolicy, Preset, Profile, Symlink, TmpBacking, }; + +mod builder; +mod factory; +pub(crate) mod layout; +mod presets; +mod types; +pub(crate) mod validation; diff --git a/src/reloaded-code-bubblewrap/src/profile/presets.rs b/src/reloaded-code-bubblewrap/src/profile/presets.rs index a7df327f..d0311281 100644 --- a/src/reloaded-code-bubblewrap/src/profile/presets.rs +++ b/src/reloaded-code-bubblewrap/src/profile/presets.rs @@ -12,6 +12,35 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; +const DEFAULT_SANDBOX_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin"; +const PUBLIC_BOT_PREFIXES: &[&str] = &[ + "/usr/bin", + "/usr/sbin", + "/usr/lib", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/local/lib", + "/bin", + "/sbin", + "/lib", + "/lib64", + "/run/current-system/sw", + "/nix/store", + "/nix/var/nix/profiles/default", +]; +const SYNTHETIC_HOME_CONFIG: &str = "/home/sandbox/.config"; +const SYNTHETIC_HOME_DEST: &str = "/home/sandbox"; +const TRUSTED_DENY_PREFIXES: &[&str] = &[ + "/home", + "/root", + "/tmp", + "/var/tmp", + "/run/user", + "/run/wrappers/bin", + "/etc/profiles/per-user", +]; +const WORKSPACE_DEST: &str = "/workspace"; + impl Builder { /// Creates the public-bot preset builder. /// @@ -116,36 +145,6 @@ impl Builder { } } -const SYNTHETIC_HOME_DEST: &str = "/home/sandbox"; -const SYNTHETIC_HOME_CONFIG: &str = "/home/sandbox/.config"; -const WORKSPACE_DEST: &str = "/workspace"; - -const DEFAULT_SANDBOX_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin"; -const PUBLIC_BOT_PREFIXES: &[&str] = &[ - "/usr/bin", - "/usr/sbin", - "/usr/lib", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/local/lib", - "/bin", - "/sbin", - "/lib", - "/lib64", - "/run/current-system/sw", - "/nix/store", - "/nix/var/nix/profiles/default", -]; -const TRUSTED_DENY_PREFIXES: &[&str] = &[ - "/home", - "/root", - "/tmp", - "/var/tmp", - "/run/user", - "/run/wrappers/bin", - "/etc/profiles/per-user", -]; - /// Builds a filtered `PATH` string from the host environment for the given [`Preset`]. /// /// Each host entry is checked with [`path_entry_allowed`]; entries that fail the @@ -185,24 +184,24 @@ fn inherited_path(preset: Preset) -> String { } } -/// Checks whether a `PATH` entry is safe to include for the given [`Preset`]. +/// Collects compatibility symlinks for [`Preset::PublicBot`]. /// -/// The caller must pass an absolute, normalized path. For [`Preset::PublicBot`] -/// only entries under [`PUBLIC_BOT_PREFIXES`] are allowed. For -/// [`Preset::TrustedMaintenance`] everything is allowed except entries under -/// [`TRUSTED_DENY_PREFIXES`]. -fn path_entry_allowed(preset: Preset, entry: &Path) -> bool { - match preset { - Preset::PublicBot => PUBLIC_BOT_PREFIXES - .iter() - .any(|prefix| entry.starts_with(prefix)), - Preset::TrustedMaintenance => { - entry.is_absolute() - && !TRUSTED_DENY_PREFIXES - .iter() - .any(|prefix| entry.starts_with(prefix)) +/// On systems without a merged `/usr` layout, `/bin`, `/lib`, and `/sbin` may +/// not exist as symlinks to their `/usr` counterparts. This function checks +/// each candidate and includes only those where the link path is absent and +/// the target directory exists on the host. +fn public_bot_compat_symlinks() -> Arc<[Symlink]> { + let mut symlinks = Vec::with_capacity(3); + for (target, link_path, required_path) in [ + ("usr/bin", "/bin", "/usr/bin"), + ("usr/lib", "/lib", "/usr/lib"), + ("usr/sbin", "/sbin", "/usr/sbin"), + ] { + if !Path::new(link_path).exists() && Path::new(required_path).exists() { + symlinks.push(Symlink::new(target, Path::new(link_path))); } } + symlinks.into() } /// Collects host directories to mount read-only for [`Preset::PublicBot`]. @@ -220,24 +219,24 @@ fn public_bot_read_only_mounts() -> Arc<[Box]> { mounts.into() } -/// Collects compatibility symlinks for [`Preset::PublicBot`]. +/// Checks whether a `PATH` entry is safe to include for the given [`Preset`]. /// -/// On systems without a merged `/usr` layout, `/bin`, `/lib`, and `/sbin` may -/// not exist as symlinks to their `/usr` counterparts. This function checks -/// each candidate and includes only those where the link path is absent and -/// the target directory exists on the host. -fn public_bot_compat_symlinks() -> Arc<[Symlink]> { - let mut symlinks = Vec::with_capacity(3); - for (target, link_path, required_path) in [ - ("usr/bin", "/bin", "/usr/bin"), - ("usr/lib", "/lib", "/usr/lib"), - ("usr/sbin", "/sbin", "/usr/sbin"), - ] { - if !Path::new(link_path).exists() && Path::new(required_path).exists() { - symlinks.push(Symlink::new(target, Path::new(link_path))); +/// The caller must pass an absolute, normalized path. For [`Preset::PublicBot`] +/// only entries under [`PUBLIC_BOT_PREFIXES`] are allowed. For +/// [`Preset::TrustedMaintenance`] everything is allowed except entries under +/// [`TRUSTED_DENY_PREFIXES`]. +fn path_entry_allowed(preset: Preset, entry: &Path) -> bool { + match preset { + Preset::PublicBot => PUBLIC_BOT_PREFIXES + .iter() + .any(|prefix| entry.starts_with(prefix)), + Preset::TrustedMaintenance => { + entry.is_absolute() + && !TRUSTED_DENY_PREFIXES + .iter() + .any(|prefix| entry.starts_with(prefix)) } } - symlinks.into() } #[cfg(test)] diff --git a/src/reloaded-code-bubblewrap/src/profile/types.rs b/src/reloaded-code-bubblewrap/src/profile/types.rs index 7fbb54e2..94bce0aa 100644 --- a/src/reloaded-code-bubblewrap/src/profile/types.rs +++ b/src/reloaded-code-bubblewrap/src/profile/types.rs @@ -13,48 +13,39 @@ use std::ffi::OsString; use std::path::Path; use std::sync::Arc; -/// Preset names for common sandbox setups. +/// A validated bubblewrap profile ready for repeated command wrapping. /// -/// [`Self::TrustedMaintenance`] is only for trusted jobs. It keeps network -/// access enabled, so a command can send out any data it can read. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Preset { - /// Safer defaults for untrusted or public input. - /// - /// This preset mounts selected system paths, the workspace, the synthetic - /// home, `/dev`, `/proc`, and `/tmp`. It does not expose the real home - /// directory or inherited env vars. - PublicBot, - /// Broader defaults for trusted jobs. - /// - /// This preset keeps network access enabled and exposes the host root - /// read-only. Do not use it for untrusted input. - TrustedMaintenance, -} - -/// Network policy for Linux sandbox execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum NetworkPolicy { - /// Network access is disabled (default). - #[default] - Disabled, - /// Network access is enabled. - Enabled, -} - -/// How sandbox `/tmp` is mounted. +/// Build this with [`crate::profile::Builder::build`](crate::profile::Builder::build). /// -/// Use [`Self::Tmpfs`] to keep `/tmp` in memory. Use [`Self::BindHost`] to -/// mount a host directory at `/tmp`. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum TmpBacking { - /// Mount `/tmp` as tmpfs inside the sandbox. - #[default] - Tmpfs, - /// Mount a host directory at sandbox `/tmp`. - /// - /// You create and clean up the directory. - BindHost(Box), +/// The build step validates profile-owned paths, resolves the `bwrap` binary, +/// picks a visible host shell, and precomputes the static `bwrap` argv prefix. +/// [`crate::wrap::wrap_command`] only needs to map the per-call working +/// directory and append the shell command tail. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub(crate) preset: Option, + pub(crate) workspace: Box, + pub(crate) workspace_dest: Box, + pub(crate) synthetic_home: Box, + pub(crate) synthetic_home_dest: Box, + pub(crate) cache_root: Box, + pub(crate) tmp_backing: TmpBacking, + pub(crate) mount_cache_root: bool, + pub(crate) compat_symlinks: Arc<[Symlink]>, + pub(crate) read_only_mounts: Arc<[Box]>, + pub(crate) read_write_mounts: Arc<[Box]>, + pub(crate) tmpfs_overlays: Arc<[Box]>, + pub(crate) file_overlays: Arc<[FileOverlay]>, + pub(crate) credential_file_mounts: Arc<[FileMount]>, + pub(crate) read_only_host_rootfs: bool, + pub(crate) network_policy: NetworkPolicy, + pub(crate) clear_env: bool, + pub(crate) default_env: Arc<[EnvVar]>, + pub(crate) extra_env: Arc<[EnvVar]>, + pub(crate) availability: Availability, + pub(crate) bwrap_program: Arc, + pub(crate) shell: Box, + pub(crate) static_args: Arc<[OsString]>, } /// Whether bubblewrap can run. @@ -73,47 +64,6 @@ pub enum Availability { }, } -impl Availability { - /// Checks whether bubblewrap can run in the current process. - /// - /// # Returns - /// - [`Availability::Available`] when `bwrap` is present and usable. - /// - [`Availability::Unavailable`] with an actionable reason otherwise. - pub fn detect() -> Self { - crate::probe::probe_availability() - } - - /// Creates an unavailable state with a reason. - /// - /// # Examples - /// ``` - /// use reloaded_code_bubblewrap::profile::Availability; - /// - /// let avail = Availability::unavailable("bwrap not found"); - /// assert!(!avail.is_available()); - /// ``` - pub fn unavailable(reason: impl Into>) -> Self { - Self::Unavailable { - reason: reason.into(), - } - } - - /// Returns the reason when bubblewrap is unavailable. - /// - /// Returns `None` for `Unknown` and `Available`. - pub fn reason(&self) -> Option<&str> { - match self { - Self::Unavailable { reason } => Some(reason.as_ref()), - Self::Unknown | Self::Available => None, - } - } - - /// Returns whether bubblewrap is known to be available. - pub fn is_available(&self) -> bool { - matches!(self, Self::Available) - } -} - /// One environment variable for the sandbox. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnvVar { @@ -121,61 +71,6 @@ pub struct EnvVar { value: Box, } -impl EnvVar { - /// Creates an environment variable. - /// - /// # Arguments - /// - `name` - The variable name, such as `PATH` or `HOME`. - /// - `value` - The variable value. - pub fn new(name: impl Into>, value: impl Into>) -> Self { - Self { - name: name.into(), - value: value.into(), - } - } - - /// Returns the variable name. - pub fn name(&self) -> &str { - &self.name - } - - /// Returns the variable value. - pub fn value(&self) -> &str { - &self.value - } -} - -/// One symlink to create inside the sandbox root. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Symlink { - target: Box, - link_path: Box, -} - -impl Symlink { - /// Creates a symlink entry. - /// - /// # Arguments - /// - `target` - The symlink target path. - /// - `link_path` - The path where the symlink is created inside the sandbox. - pub fn new(target: impl Into>, link_path: impl Into>) -> Self { - Self { - target: target.into(), - link_path: link_path.into(), - } - } - - /// Returns the symlink target. - pub fn target(&self) -> &str { - &self.target - } - - /// Returns the link path inside the sandbox. - pub fn link_path(&self) -> &Path { - &self.link_path - } -} - /// One read-only file mount inside the sandbox. /// /// # Validation @@ -190,30 +85,6 @@ pub struct FileMount { dest: Box, } -impl FileMount { - /// Creates a file mount. - /// - /// # Arguments - /// - `source` - The source file path on the host. - /// - `dest` - The destination path inside the sandbox. - pub fn new(source: impl Into>, dest: impl Into>) -> Self { - Self { - source: source.into(), - dest: dest.into(), - } - } - - /// Returns the source file path on the host. - pub fn source(&self) -> &Path { - &self.source - } - - /// Returns the destination path inside the sandbox. - pub fn dest(&self) -> &Path { - &self.dest - } -} - /// One read-only file overlay inside the sandbox. /// /// Replaces a file anywhere in the sandbox rootfs with content from a host @@ -230,63 +101,55 @@ pub struct FileOverlay { dest: Box, } -impl FileOverlay { - /// Creates a file overlay. - /// - /// # Arguments - /// - `source` - The host file whose content is bind-mounted read-only. - /// - `dest` - The sandbox path to be replaced. - pub fn new(source: impl Into>, dest: impl Into>) -> Self { - Self { - source: source.into(), - dest: dest.into(), - } - } +/// Network policy for Linux sandbox execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NetworkPolicy { + /// Network access is disabled (default). + #[default] + Disabled, + /// Network access is enabled. + Enabled, +} - /// Returns the host source file path. - pub fn source(&self) -> &Path { - &self.source - } +/// Preset names for common sandbox setups. +/// +/// [`Self::TrustedMaintenance`] is only for trusted jobs. It keeps network +/// access enabled, so a command can send out any data it can read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preset { + /// Safer defaults for untrusted or public input. + /// + /// This preset mounts selected system paths, the workspace, the synthetic + /// home, `/dev`, `/proc`, and `/tmp`. It does not expose the real home + /// directory or inherited env vars. + PublicBot, + /// Broader defaults for trusted jobs. + /// + /// This preset keeps network access enabled and exposes the host root + /// read-only. Do not use it for untrusted input. + TrustedMaintenance, +} - /// Returns the sandbox destination path. - pub fn dest(&self) -> &Path { - &self.dest - } +/// One symlink to create inside the sandbox root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Symlink { + target: Box, + link_path: Box, } -/// A validated bubblewrap profile ready for repeated command wrapping. -/// -/// Build this with [`crate::profile::Builder::build`](crate::profile::Builder::build). +/// How sandbox `/tmp` is mounted. /// -/// The build step validates profile-owned paths, resolves the `bwrap` binary, -/// picks a visible host shell, and precomputes the static `bwrap` argv prefix. -/// [`crate::wrap::wrap_command`] only needs to map the per-call working -/// directory and append the shell command tail. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Profile { - pub(crate) preset: Option, - pub(crate) workspace: Box, - pub(crate) workspace_dest: Box, - pub(crate) synthetic_home: Box, - pub(crate) synthetic_home_dest: Box, - pub(crate) cache_root: Box, - pub(crate) tmp_backing: TmpBacking, - pub(crate) mount_cache_root: bool, - pub(crate) compat_symlinks: Arc<[Symlink]>, - pub(crate) read_only_mounts: Arc<[Box]>, - pub(crate) read_write_mounts: Arc<[Box]>, - pub(crate) tmpfs_overlays: Arc<[Box]>, - pub(crate) file_overlays: Arc<[FileOverlay]>, - pub(crate) credential_file_mounts: Arc<[FileMount]>, - pub(crate) read_only_host_rootfs: bool, - pub(crate) network_policy: NetworkPolicy, - pub(crate) clear_env: bool, - pub(crate) default_env: Arc<[EnvVar]>, - pub(crate) extra_env: Arc<[EnvVar]>, - pub(crate) availability: Availability, - pub(crate) bwrap_program: Arc, - pub(crate) shell: Box, - pub(crate) static_args: Arc<[OsString]>, +/// Use [`Self::Tmpfs`] to keep `/tmp` in memory. Use [`Self::BindHost`] to +/// mount a host directory at `/tmp`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum TmpBacking { + /// Mount `/tmp` as tmpfs inside the sandbox. + #[default] + Tmpfs, + /// Mount a host directory at sandbox `/tmp`. + /// + /// You create and clean up the directory. + BindHost(Box), } impl Profile { @@ -505,6 +368,143 @@ impl Profile { } } +impl Availability { + /// Checks whether bubblewrap can run in the current process. + /// + /// # Returns + /// - [`Availability::Available`] when `bwrap` is present and usable. + /// - [`Availability::Unavailable`] with an actionable reason otherwise. + pub fn detect() -> Self { + crate::probe::probe_availability() + } + + /// Creates an unavailable state with a reason. + /// + /// # Examples + /// ``` + /// use reloaded_code_bubblewrap::profile::Availability; + /// + /// let avail = Availability::unavailable("bwrap not found"); + /// assert!(!avail.is_available()); + /// ``` + pub fn unavailable(reason: impl Into>) -> Self { + Self::Unavailable { + reason: reason.into(), + } + } + + /// Returns the reason when bubblewrap is unavailable. + /// + /// Returns `None` for `Unknown` and `Available`. + pub fn reason(&self) -> Option<&str> { + match self { + Self::Unavailable { reason } => Some(reason.as_ref()), + Self::Unknown | Self::Available => None, + } + } + + /// Returns whether bubblewrap is known to be available. + pub fn is_available(&self) -> bool { + matches!(self, Self::Available) + } +} + +impl EnvVar { + /// Creates an environment variable. + /// + /// # Arguments + /// - `name` - The variable name, such as `PATH` or `HOME`. + /// - `value` - The variable value. + pub fn new(name: impl Into>, value: impl Into>) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } + + /// Returns the variable name. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the variable value. + pub fn value(&self) -> &str { + &self.value + } +} + +impl FileMount { + /// Creates a file mount. + /// + /// # Arguments + /// - `source` - The source file path on the host. + /// - `dest` - The destination path inside the sandbox. + pub fn new(source: impl Into>, dest: impl Into>) -> Self { + Self { + source: source.into(), + dest: dest.into(), + } + } + + /// Returns the source file path on the host. + pub fn source(&self) -> &Path { + &self.source + } + + /// Returns the destination path inside the sandbox. + pub fn dest(&self) -> &Path { + &self.dest + } +} + +impl FileOverlay { + /// Creates a file overlay. + /// + /// # Arguments + /// - `source` - The host file whose content is bind-mounted read-only. + /// - `dest` - The sandbox path to be replaced. + pub fn new(source: impl Into>, dest: impl Into>) -> Self { + Self { + source: source.into(), + dest: dest.into(), + } + } + + /// Returns the host source file path. + pub fn source(&self) -> &Path { + &self.source + } + + /// Returns the sandbox destination path. + pub fn dest(&self) -> &Path { + &self.dest + } +} + +impl Symlink { + /// Creates a symlink entry. + /// + /// # Arguments + /// - `target` - The symlink target path. + /// - `link_path` - The path where the symlink is created inside the sandbox. + pub fn new(target: impl Into>, link_path: impl Into>) -> Self { + Self { + target: target.into(), + link_path: link_path.into(), + } + } + + /// Returns the symlink target. + pub fn target(&self) -> &str { + &self.target + } + + /// Returns the link path inside the sandbox. + pub fn link_path(&self) -> &Path { + &self.link_path + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-bubblewrap/src/profile/validation.rs b/src/reloaded-code-bubblewrap/src/profile/validation.rs index eb51d265..1b84979e 100644 --- a/src/reloaded-code-bubblewrap/src/profile/validation.rs +++ b/src/reloaded-code-bubblewrap/src/profile/validation.rs @@ -54,51 +54,54 @@ pub(crate) fn ensure_cache_root_subdirs( Ok(()) } -/// Validates that `path` is absolute. -pub(crate) fn validate_absolute_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { - if path.is_absolute() { - Ok(()) - } else { - Err(LinuxBwrapError::InvalidPath(format!( - "{label} must be an absolute path: {}", - path.display() - ))) - } -} - -/// Validates that an optional directory path is absolute, exists, and is a directory. -pub(crate) fn validate_optional_directory_path( - path: Option<&Path>, - label: &str, -) -> Result<(), LinuxBwrapError> { - match path { - Some(path) => validate_directory_path(path, label), - None => Ok(()), +/// Checks that variable names are non-empty, contain no `=`, and neither +/// names nor values contain NUL bytes. +/// +/// NUL bytes are rejected because environment variables are stored as C strings +/// in the kernel's `environ` array - a NUL would silently truncate the string +/// at that point. +/// +/// # Errors +/// +/// Returns [`LinuxBwrapError::InvalidPath`] for the first invalid variable found. +pub(crate) fn validate_env_vars(vars: &[EnvVar], label: &str) -> Result<(), LinuxBwrapError> { + for var in vars { + if var.name().is_empty() { + return Err(LinuxBwrapError::InvalidPath(format!( + "{label} environment variable name must not be empty" + ))); + } + if var.name().contains('=') { + return Err(LinuxBwrapError::InvalidPath(format!( + "{label} environment variable name must not contain '=': {}", + var.name() + ))); + } + if var.name().contains('\0') { + return Err(LinuxBwrapError::InvalidPath(format!( + "{label} environment variable name must not contain NUL: {}", + var.name() + ))); + } + if var.value().contains('\0') { + return Err(LinuxBwrapError::InvalidPath(format!( + "{label} environment variable value must not contain NUL: {}", + var.name() + ))); + } } + Ok(()) } -/// Validates that `path` is an absolute existing directory. -pub(crate) fn validate_directory_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { - validate_absolute_path(path, label)?; - let metadata = fs::metadata(path).map_err(|_| { - LinuxBwrapError::InvalidPath(format!("{label} does not exist: {}", path.display())) - })?; - if metadata.is_dir() { - Ok(()) - } else { - Err(LinuxBwrapError::InvalidPath(format!( - "{label} is not a directory: {}", - path.display() - ))) +/// Validates file overlay entries. +/// +/// The source must be an absolute path that exists on the host. The destination +/// must be an absolute path. +pub(crate) fn validate_file_overlays(overlays: &[FileOverlay]) -> Result<(), LinuxBwrapError> { + for overlay in overlays { + validate_existing_path(overlay.source(), "file overlay source")?; + validate_absolute_path(overlay.dest(), "file overlay destination")?; } -} - -/// Validates that `path` is an absolute existing path. -pub(crate) fn validate_existing_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { - validate_absolute_path(path, label)?; - fs::metadata(path).map_err(|_| { - LinuxBwrapError::InvalidPath(format!("{label} does not exist: {}", path.display())) - })?; Ok(()) } @@ -113,24 +116,15 @@ pub(crate) fn validate_mount_paths( Ok(()) } -/// Validates tmpfs overlay destinations. -pub(crate) fn validate_tmpfs_overlays(overlays: &[Box]) -> Result<(), LinuxBwrapError> { - for overlay in overlays { - validate_absolute_path(overlay, "tmpfs overlay path")?; - } - Ok(()) -} - -/// Validates file overlay entries. -/// -/// The source must be an absolute path that exists on the host. The destination -/// must be an absolute path. -pub(crate) fn validate_file_overlays(overlays: &[FileOverlay]) -> Result<(), LinuxBwrapError> { - for overlay in overlays { - validate_existing_path(overlay.source(), "file overlay source")?; - validate_absolute_path(overlay.dest(), "file overlay destination")?; +/// Validates that an optional directory path is absolute, exists, and is a directory. +pub(crate) fn validate_optional_directory_path( + path: Option<&Path>, + label: &str, +) -> Result<(), LinuxBwrapError> { + match path { + Some(path) => validate_directory_path(path, label), + None => Ok(()), } - Ok(()) } /// Checks that every symlink has a non-empty target and an absolute link path. @@ -152,45 +146,6 @@ pub(crate) fn validate_symlinks(symlinks: &[Symlink]) -> Result<(), LinuxBwrapEr Ok(()) } -/// Checks that variable names are non-empty, contain no `=`, and neither -/// names nor values contain NUL bytes. -/// -/// NUL bytes are rejected because environment variables are stored as C strings -/// in the kernel's `environ` array - a NUL would silently truncate the string -/// at that point. -/// -/// # Errors -/// -/// Returns [`LinuxBwrapError::InvalidPath`] for the first invalid variable found. -pub(crate) fn validate_env_vars(vars: &[EnvVar], label: &str) -> Result<(), LinuxBwrapError> { - for var in vars { - if var.name().is_empty() { - return Err(LinuxBwrapError::InvalidPath(format!( - "{label} environment variable name must not be empty" - ))); - } - if var.name().contains('=') { - return Err(LinuxBwrapError::InvalidPath(format!( - "{label} environment variable name must not contain '=': {}", - var.name() - ))); - } - if var.name().contains('\0') { - return Err(LinuxBwrapError::InvalidPath(format!( - "{label} environment variable name must not contain NUL: {}", - var.name() - ))); - } - if var.value().contains('\0') { - return Err(LinuxBwrapError::InvalidPath(format!( - "{label} environment variable value must not contain NUL: {}", - var.name() - ))); - } - } - Ok(()) -} - /// Validates that bind-backed `/tmp` targets an existing directory other than /// the host `/tmp` itself. [`TmpBacking::Tmpfs`] always passes. /// @@ -215,3 +170,48 @@ pub(crate) fn validate_tmp_backing(tmp_backing: &TmpBacking) -> Result<(), Linux } } } + +/// Validates tmpfs overlay destinations. +pub(crate) fn validate_tmpfs_overlays(overlays: &[Box]) -> Result<(), LinuxBwrapError> { + for overlay in overlays { + validate_absolute_path(overlay, "tmpfs overlay path")?; + } + Ok(()) +} + +/// Validates that `path` is an absolute existing directory. +pub(crate) fn validate_directory_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { + validate_absolute_path(path, label)?; + let metadata = fs::metadata(path).map_err(|_| { + LinuxBwrapError::InvalidPath(format!("{label} does not exist: {}", path.display())) + })?; + if metadata.is_dir() { + Ok(()) + } else { + Err(LinuxBwrapError::InvalidPath(format!( + "{label} is not a directory: {}", + path.display() + ))) + } +} + +/// Validates that `path` is an absolute existing path. +pub(crate) fn validate_existing_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { + validate_absolute_path(path, label)?; + fs::metadata(path).map_err(|_| { + LinuxBwrapError::InvalidPath(format!("{label} does not exist: {}", path.display())) + })?; + Ok(()) +} + +/// Validates that `path` is absolute. +pub(crate) fn validate_absolute_path(path: &Path, label: &str) -> Result<(), LinuxBwrapError> { + if path.is_absolute() { + Ok(()) + } else { + Err(LinuxBwrapError::InvalidPath(format!( + "{label} must be an absolute path: {}", + path.display() + ))) + } +} diff --git a/src/reloaded-code-bubblewrap/src/test_helpers.rs b/src/reloaded-code-bubblewrap/src/test_helpers.rs index 6fbcc78c..d376d0e1 100644 --- a/src/reloaded-code-bubblewrap/src/test_helpers.rs +++ b/src/reloaded-code-bubblewrap/src/test_helpers.rs @@ -15,144 +15,18 @@ use tempfile::TempDir; const DEFAULT_FAKE_SHELL: &str = "#!/bin/sh\nexit 0\n"; +/// Shared sandbox fixture with fake binaries and a managed `PATH`. +pub(crate) struct SandboxFixture { + dirs: SandboxDirs, + _path_guard: PathGuard, +} + /// Captures the original `PATH` and restores it on drop. /// /// Used alongside [`replace_path`] or [`prepend_path`] so that test /// environment changes are automatically cleaned up. pub(crate) struct PathGuard(Option); -impl PathGuard { - /// Snapshots the current `PATH` value (or notes that it is unset). - pub(crate) fn capture() -> Self { - Self(env::var_os("PATH")) - } -} - -impl Drop for PathGuard { - /// Restores the `PATH` that was active when this guard was created. - /// - /// Uses `unsafe` `env::set_var` / `env::remove_var` because Rust's safe - /// API does not permit modifying environment variables during program - /// execution. This is safe in test-only code where single-threaded - /// environment access is guaranteed. - fn drop(&mut self) { - match &self.0 { - Some(path) => unsafe { env::set_var("PATH", path) }, - None => unsafe { env::remove_var("PATH") }, - } - } -} - -/// Replaces `PATH` with `path` and returns a [`PathGuard`] that restores it. -/// -/// # Safety -/// The returned guard restores `PATH` via `unsafe` env-var APIs on drop. -/// Callers must ensure no other thread reads `PATH` concurrently. -pub(crate) fn replace_path(path: &Path) -> PathGuard { - let guard = PathGuard::capture(); - unsafe { env::set_var("PATH", path) }; - guard -} - -/// Prepends `path` to `PATH` and returns a [`PathGuard`] that restores it. -/// -/// If the current `PATH` is empty or unset, the result is just `path`. -/// -/// # Safety -/// The returned guard restores `PATH` via `unsafe` env-var APIs on drop. -/// Callers must ensure no other thread reads `PATH` concurrently. -pub(crate) fn prepend_path(path: &Path) -> PathGuard { - let guard = PathGuard::capture(); - let prefix = path.to_string_lossy(); - let original = guard.0.as_ref().map(|value| value.to_string_lossy()); - let capacity = prefix.len() + original.as_ref().map_or(0, |value| value.len() + 1); - let mut new_path = String::with_capacity(capacity); - new_path.push_str(&prefix); - if let Some(original) = original { - if !original.is_empty() { - new_path.push(':'); - new_path.push_str(&original); - } - } - unsafe { env::set_var("PATH", &new_path) }; - guard -} - -/// Writes `contents` to `path` and marks it executable on Unix. -/// -/// # Panics -/// Propagates any I/O error from the write or permission change. -pub(crate) fn write_executable(path: &Path, contents: impl AsRef<[u8]>) { - fs::write(path, contents).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let mut perms = fs::metadata(path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms).unwrap(); - } -} - -/// Writes an executable script inside `dir` and returns its path. -/// -/// # Panics -/// Propagates any I/O error. -pub(crate) fn write_script(dir: &Path, name: &str, body: &str) -> PathBuf { - let path = dir.join(name); - write_executable(&path, body.as_bytes()); - path -} - -/// Creates a fake `bash` binary that exits successfully. -/// -/// The script is a minimal `/bin/sh` wrapper that returns exit code 0. -pub(crate) fn create_fake_shell(dir: &Path) -> PathBuf { - write_script(dir, "bash", DEFAULT_FAKE_SHELL) -} - -/// Creates a fake `bwrap` script in `dir`. -/// -/// Returns the log file path. The fake binary handles `--version` and the -/// probe command itself, logs other arguments to `bwrap.log`, and then runs -/// `behavior`. -pub(crate) fn create_fake_bwrap(dir: &Path, behavior: &str) -> PathBuf { - let bwrap_path = dir.join("bwrap"); - let log_path = dir.join("bwrap.log"); - let log_path_escaped = log_path.to_string_lossy().replace('\'', "'\\''"); - let script = format!( - r#"#!/bin/sh -# Handle --version probe -for arg in "$@"; do - if [ "$arg" = "--version" ]; then - echo "bubblewrap 0.8.0" - exit 0 - fi -done -# Handle capability probe via the unique shell command marker. -for arg in "$@"; do - case "$arg" in - {probe_arg0}) - exit 0 - ;; - esac -done -# Log arguments for verification -for a in "$@"; do - printf '%s\n' "$a" >> '{log_path_escaped}' -done -echo "" >> '{log_path_escaped}' -# Execute the provided behavior -{behavior} -"#, - behavior = behavior, - log_path_escaped = log_path_escaped, - probe_arg0 = PROBE_ARG0, - ); - write_executable(&bwrap_path, script.as_bytes()); - log_path -} - /// Standard sandbox directory layout used across tests. /// /// Owns a [`TempDir`] containing `workspace`, `home`, and `cache` @@ -164,64 +38,6 @@ pub(crate) struct SandboxDirs { cache: PathBuf, } -impl SandboxDirs { - /// Creates a tempdir with `workspace`, `home`, and `cache` subdirectories. - /// - /// # Panics - /// Propagates any I/O error from tempdir creation or `create_dir_all`. - pub(crate) fn new() -> Self { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let home = temp.path().join("home"); - let cache = temp.path().join("cache"); - fs::create_dir(&workspace).unwrap(); - fs::create_dir(&home).unwrap(); - fs::create_dir(&cache).unwrap(); - Self { - temp, - workspace, - home, - cache, - } - } - - /// Returns the temp root path. - pub(crate) fn temp_path(&self) -> &Path { - self.temp.path() - } - - /// Returns the workspace path. - pub(crate) fn workspace(&self) -> &Path { - &self.workspace - } - - /// Returns the home path. - pub(crate) fn home(&self) -> &Path { - &self.home - } - - /// Returns the cache path. - pub(crate) fn cache(&self) -> &Path { - &self.cache - } - - /// Creates a named directory inside the temp root. - /// - /// # Panics - /// Propagates any I/O error. - pub(crate) fn make_dir(&self, name: &str) -> PathBuf { - let path = self.temp_path().join(name); - fs::create_dir_all(&path).unwrap(); - path - } -} - -/// Shared sandbox fixture with fake binaries and a managed `PATH`. -pub(crate) struct SandboxFixture { - dirs: SandboxDirs, - _path_guard: PathGuard, -} - impl SandboxFixture { /// Creates a fixture whose temp root fully replaces `PATH`. pub(crate) fn new(bwrap_behavior: &str) -> Self { @@ -310,6 +126,80 @@ impl SandboxFixture { } } +impl PathGuard { + /// Snapshots the current `PATH` value (or notes that it is unset). + pub(crate) fn capture() -> Self { + Self(env::var_os("PATH")) + } +} + +impl SandboxDirs { + /// Creates a tempdir with `workspace`, `home`, and `cache` subdirectories. + /// + /// # Panics + /// Propagates any I/O error from tempdir creation or `create_dir_all`. + pub(crate) fn new() -> Self { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let home = temp.path().join("home"); + let cache = temp.path().join("cache"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&home).unwrap(); + fs::create_dir(&cache).unwrap(); + Self { + temp, + workspace, + home, + cache, + } + } + + /// Returns the temp root path. + pub(crate) fn temp_path(&self) -> &Path { + self.temp.path() + } + + /// Returns the workspace path. + pub(crate) fn workspace(&self) -> &Path { + &self.workspace + } + + /// Returns the home path. + pub(crate) fn home(&self) -> &Path { + &self.home + } + + /// Returns the cache path. + pub(crate) fn cache(&self) -> &Path { + &self.cache + } + + /// Creates a named directory inside the temp root. + /// + /// # Panics + /// Propagates any I/O error. + pub(crate) fn make_dir(&self, name: &str) -> PathBuf { + let path = self.temp_path().join(name); + fs::create_dir_all(&path).unwrap(); + path + } +} + +impl Drop for PathGuard { + /// Restores the `PATH` that was active when this guard was created. + /// + /// Uses `unsafe` `env::set_var` / `env::remove_var` because Rust's safe + /// API does not permit modifying environment variables during program + /// execution. This is safe in test-only code where single-threaded + /// environment access is guaranteed. + fn drop(&mut self) { + match &self.0 { + Some(path) => unsafe { env::set_var("PATH", path) }, + None => unsafe { env::remove_var("PATH") }, + } + } +} + /// Converts command args into owned strings for assertions. pub(crate) fn args_as_strings<'a>( args: impl IntoIterator, @@ -318,3 +208,113 @@ pub(crate) fn args_as_strings<'a>( .map(|arg| arg.to_string_lossy().into_owned()) .collect() } + +/// Creates a fake `bwrap` script in `dir`. +/// +/// Returns the log file path. The fake binary handles `--version` and the +/// probe command itself, logs other arguments to `bwrap.log`, and then runs +/// `behavior`. +pub(crate) fn create_fake_bwrap(dir: &Path, behavior: &str) -> PathBuf { + let bwrap_path = dir.join("bwrap"); + let log_path = dir.join("bwrap.log"); + let log_path_escaped = log_path.to_string_lossy().replace('\'', "'\\''"); + let script = format!( + r#"#!/bin/sh +# Handle --version probe +for arg in "$@"; do + if [ "$arg" = "--version" ]; then + echo "bubblewrap 0.8.0" + exit 0 + fi +done +# Handle capability probe via the unique shell command marker. +for arg in "$@"; do + case "$arg" in + {probe_arg0}) + exit 0 + ;; + esac +done +# Log arguments for verification +for a in "$@"; do + printf '%s\n' "$a" >> '{log_path_escaped}' +done +echo "" >> '{log_path_escaped}' +# Execute the provided behavior +{behavior} +"#, + behavior = behavior, + log_path_escaped = log_path_escaped, + probe_arg0 = PROBE_ARG0, + ); + write_executable(&bwrap_path, script.as_bytes()); + log_path +} + +/// Creates a fake `bash` binary that exits successfully. +/// +/// The script is a minimal `/bin/sh` wrapper that returns exit code 0. +pub(crate) fn create_fake_shell(dir: &Path) -> PathBuf { + write_script(dir, "bash", DEFAULT_FAKE_SHELL) +} + +/// Prepends `path` to `PATH` and returns a [`PathGuard`] that restores it. +/// +/// If the current `PATH` is empty or unset, the result is just `path`. +/// +/// # Safety +/// The returned guard restores `PATH` via `unsafe` env-var APIs on drop. +/// Callers must ensure no other thread reads `PATH` concurrently. +pub(crate) fn prepend_path(path: &Path) -> PathGuard { + let guard = PathGuard::capture(); + let prefix = path.to_string_lossy(); + let original = guard.0.as_ref().map(|value| value.to_string_lossy()); + let capacity = prefix.len() + original.as_ref().map_or(0, |value| value.len() + 1); + let mut new_path = String::with_capacity(capacity); + new_path.push_str(&prefix); + if let Some(original) = original { + if !original.is_empty() { + new_path.push(':'); + new_path.push_str(&original); + } + } + unsafe { env::set_var("PATH", &new_path) }; + guard +} + +/// Replaces `PATH` with `path` and returns a [`PathGuard`] that restores it. +/// +/// # Safety +/// The returned guard restores `PATH` via `unsafe` env-var APIs on drop. +/// Callers must ensure no other thread reads `PATH` concurrently. +pub(crate) fn replace_path(path: &Path) -> PathGuard { + let guard = PathGuard::capture(); + unsafe { env::set_var("PATH", path) }; + guard +} + +/// Writes an executable script inside `dir` and returns its path. +/// +/// # Panics +/// Propagates any I/O error. +pub(crate) fn write_script(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + write_executable(&path, body.as_bytes()); + path +} + +/// Writes `contents` to `path` and marks it executable on Unix. +/// +/// # Panics +/// Propagates any I/O error from the write or permission change. +pub(crate) fn write_executable(path: &Path, contents: impl AsRef<[u8]>) { + fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); + } +} diff --git a/src/reloaded-code-bubblewrap/src/wrap/command.rs b/src/reloaded-code-bubblewrap/src/wrap/command.rs index be2dad44..918a180b 100644 --- a/src/reloaded-code-bubblewrap/src/wrap/command.rs +++ b/src/reloaded-code-bubblewrap/src/wrap/command.rs @@ -57,19 +57,6 @@ impl<'a> LinuxBwrapWrappedCommand<'a> { } } -#[inline] -fn resolve_sandbox_cwd<'a>( - profile: &'a Profile, - workdir: Option<&'a Path>, -) -> Result, LinuxBwrapError> { - if let Some(dir) = workdir { - if !profile.is_prevalidated_workdir(dir) { - validate_workdir(Some(dir))?; - } - } - profile.map_workdir_to_sandbox(workdir) -} - /// Builds a `bwrap` command line that runs `command` inside the sandbox /// described by `profile`. /// @@ -93,6 +80,19 @@ pub fn wrap_command<'a>( }) } +#[inline] +fn resolve_sandbox_cwd<'a>( + profile: &'a Profile, + workdir: Option<&'a Path>, +) -> Result, LinuxBwrapError> { + if let Some(dir) = workdir { + if !profile.is_prevalidated_workdir(dir) { + validate_workdir(Some(dir))?; + } + } + profile.map_workdir_to_sandbox(workdir) +} + /// Rejects non-absolute or non-existent working directories. fn validate_workdir(workdir: Option<&Path>) -> Result<(), LinuxBwrapError> { validate_optional_directory_path(workdir, "working directory") diff --git a/src/reloaded-code-bubblewrap/src/wrap/mod.rs b/src/reloaded-code-bubblewrap/src/wrap/mod.rs index e16de045..fca80efc 100644 --- a/src/reloaded-code-bubblewrap/src/wrap/mod.rs +++ b/src/reloaded-code-bubblewrap/src/wrap/mod.rs @@ -15,12 +15,11 @@ //! - `blocking` - enables the `blocking` submodule (sync) //! - `tokio` - enables the `tokio` submodule (async) -pub(crate) mod command; +pub use crate::LinuxBwrapError; +pub use command::{wrap_command, LinuxBwrapWrappedCommand}; #[cfg(feature = "blocking")] pub mod blocking; +pub(crate) mod command; #[cfg(feature = "tokio")] pub mod tokio; - -pub use crate::LinuxBwrapError; -pub use command::{wrap_command, LinuxBwrapWrappedCommand}; diff --git a/src/reloaded-code-core/benches/common/corpus_large.rs b/src/reloaded-code-core/benches/common/corpus_large.rs index a7f0aa0f..22486ba4 100644 --- a/src/reloaded-code-core/benches/common/corpus_large.rs +++ b/src/reloaded-code-core/benches/common/corpus_large.rs @@ -13,7 +13,6 @@ use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; - use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; @@ -29,7 +28,6 @@ use crate::tools::sandboxing::ToolCtx; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; - use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; @@ -62,13 +60,6 @@ const UNIFIED_EXEC_ENV: [(&str, &str); 8] = [ ("GIT_PAGER", "cat"), ]; -fn apply_unified_exec_env(mut env: HashMap) -> HashMap { - for (key, value) in UNIFIED_EXEC_ENV { - env.insert(key.to_string(), value.to_string()); - } - env -} - struct PreparedSessionHandles { writer_tx: mpsc::Sender>, output_buffer: OutputBuffer, @@ -80,6 +71,19 @@ struct PreparedSessionHandles { process_id: String, } +enum SessionStatus { + Alive { + exit_code: Option, + call_id: String, + process_id: String, + }, + Exited { + exit_code: Option, + entry: Box, + }, + Unknown, +} + impl UnifiedExecSessionManager { pub(crate) async fn allocate_process_id(&self) -> String { loop { @@ -651,17 +655,11 @@ impl UnifiedExecSessionManager { } } -enum SessionStatus { - Alive { - exit_code: Option, - call_id: String, - process_id: String, - }, - Exited { - exit_code: Option, - entry: Box, - }, - Unknown, +fn apply_unified_exec_env(mut env: HashMap) -> HashMap { + for (key, value) in UNIFIED_EXEC_ENV { + env.insert(key.to_string(), value.to_string()); + } + env } #[cfg(test)] diff --git a/src/reloaded-code-core/benches/common/corpus_medium.rs b/src/reloaded-code-core/benches/common/corpus_medium.rs index 89f82ec4..aaee15dc 100644 --- a/src/reloaded-code-core/benches/common/corpus_medium.rs +++ b/src/reloaded-code-core/benches/common/corpus_medium.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; - use codex_core::protocol::Event; use mcp_types::JSONRPC_VERSION; use mcp_types::JSONRPCError; @@ -21,7 +20,6 @@ use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tracing::warn; - use crate::error_code::INTERNAL_ERROR_CODE; /// Sends messages to the client and manages request callbacks. @@ -31,6 +29,59 @@ pub(crate) struct OutgoingMessageSender { request_id_to_callback: Mutex>>, } +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingNotificationParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + + #[serde(flatten)] + pub event: serde_json::Value, +} + +/// Outgoing message from the server to the client. +pub(crate) enum OutgoingMessage { + Request(OutgoingRequest), + Notification(OutgoingNotification), + Response(OutgoingResponse), + Error(OutgoingError), +} + +// Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta +// MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta +// Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OutgoingNotificationMeta { + pub request_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingError { + pub error: JSONRPCErrorError, + pub id: RequestId, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingNotification { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingRequest { + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingResponse { + pub id: RequestId, + pub result: Result, +} + impl OutgoingMessageSender { pub(crate) fn new(sender: mpsc::UnboundedSender) -> Self { Self { @@ -139,12 +190,10 @@ impl OutgoingMessageSender { } } -/// Outgoing message from the server to the client. -pub(crate) enum OutgoingMessage { - Request(OutgoingRequest), - Notification(OutgoingNotification), - Response(OutgoingResponse), - Error(OutgoingError), +impl OutgoingNotificationMeta { + pub(crate) fn new(request_id: Option) -> Self { + Self { request_id } + } } impl From for JSONRPCMessage { @@ -182,57 +231,6 @@ impl From for JSONRPCMessage { } } -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingRequest { - pub id: RequestId, - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingNotification { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingNotificationParams { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - - #[serde(flatten)] - pub event: serde_json::Value, -} - -// Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta -// MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta -// Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OutgoingNotificationMeta { - pub request_id: Option, -} - -impl OutgoingNotificationMeta { - pub(crate) fn new(request_id: Option) -> Self { - Self { request_id } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingResponse { - pub id: RequestId, - pub result: Result, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingError { - pub error: JSONRPCErrorError, - pub id: RequestId, -} - #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/src/reloaded-code-core/benches/common/corpus_small.rs b/src/reloaded-code-core/benches/common/corpus_small.rs index 56d19429..1a409c0d 100644 --- a/src/reloaded-code-core/benches/common/corpus_small.rs +++ b/src/reloaded-code-core/benches/common/corpus_small.rs @@ -19,8 +19,6 @@ use codex_protocol::protocol::EventMsg; use std::collections::BTreeMap; use std::sync::LazyLock; -pub struct PlanHandler; - pub static PLAN_TOOL: LazyLock = LazyLock::new(|| { let mut plan_item_props = BTreeMap::new(); plan_item_props.insert("step".to_string(), JsonSchema::String { description: None }); @@ -63,6 +61,8 @@ At most one step can be in_progress at a time. }) }); +pub struct PlanHandler; + #[async_trait] impl ToolHandler for PlanHandler { fn kind(&self) -> ToolKind { diff --git a/src/reloaded-code-core/benches/common/mod.rs b/src/reloaded-code-core/benches/common/mod.rs index 43913822..fbed770a 100644 --- a/src/reloaded-code-core/benches/common/mod.rs +++ b/src/reloaded-code-core/benches/common/mod.rs @@ -19,19 +19,19 @@ //! //! 2. Population-wide averages were computed across all files: //! -//! | Metric | Population average | -//! |------------------------------|--------------------| -//! | Non-blank avg line length | 37.7 chars | -//! | Blank line ratio | ~10.5% | -//! | Avg bytes/line | 34.7 | +//! | Metric | Population average | +//! | ------------------------- | ------------------ | +//! | Non-blank avg line length | 37.7 chars | +//! | Blank line ratio | ~10.5% | +//! | Avg bytes/line | 34.7 | //! //! 3. Each candidate file was scored by distance from these population //! averages. The three files above were the closest matches across the //! small / medium / large size brackets. -const CORPUS_SMALL_RAW: &str = include_str!("corpus_small.rs"); -const CORPUS_MEDIUM_RAW: &str = include_str!("corpus_medium.rs"); const CORPUS_LARGE_RAW: &str = include_str!("corpus_large.rs"); +const CORPUS_MEDIUM_RAW: &str = include_str!("corpus_medium.rs"); +const CORPUS_SMALL_RAW: &str = include_str!("corpus_small.rs"); #[derive(Clone, Copy)] pub enum CorpusSize { @@ -40,6 +40,11 @@ pub enum CorpusSize { Large, } +#[allow(dead_code)] // Used by some benchmarks but not all +pub fn corpus_crlf(size: CorpusSize) -> String { + corpus_content(size).replace('\n', "\r\n") +} + pub fn corpus_content(size: CorpusSize) -> &'static str { match size { CorpusSize::Small => CORPUS_SMALL_RAW, @@ -47,8 +52,3 @@ pub fn corpus_content(size: CorpusSize) -> &'static str { CorpusSize::Large => CORPUS_LARGE_RAW, } } - -#[allow(dead_code)] // Used by some benchmarks but not all -pub fn corpus_crlf(size: CorpusSize) -> String { - corpus_content(size).replace('\n', "\r\n") -} diff --git a/src/reloaded-code-core/benches/model_catalog_builder.rs b/src/reloaded-code-core/benches/model_catalog_builder.rs index 283a0544..04f0e005 100644 --- a/src/reloaded-code-core/benches/model_catalog_builder.rs +++ b/src/reloaded-code-core/benches/model_catalog_builder.rs @@ -1,5 +1,9 @@ //! Benchmarks for batch model-catalog construction. +criterion_group!(benches, benchmark_builder_construction); + +criterion_main!(benches); + use core::hint::black_box; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use reloaded_code_core::models::{ @@ -7,17 +11,17 @@ use reloaded_code_core::models::{ ProviderSource, ProviderType, }; +struct Dataset { + providers: Vec, + provider_models: Vec, +} + struct ProviderModelSpec { provider_idx: ProviderIdx, model_key: String, model: ModelInfo, } -struct Dataset { - providers: Vec, - provider_models: Vec, -} - impl Dataset { fn provider_model_sources(&self) -> Vec> { let mut sources = Vec::with_capacity(self.provider_models.len()); @@ -32,6 +36,42 @@ impl Dataset { } } +fn benchmark_builder_construction(c: &mut Criterion) { + let mut group = c.benchmark_group("model_catalog_builder_construct"); + + for (name, provider_count, model_count, with_env_vars) in [ + ("models_dev_snapshot", 96usize, 3031usize, true), + ("max", 16384usize, 65535usize, false), + ] { + let dataset = make_dataset(provider_count, model_count, with_env_vars); + let provider_model_sources = dataset.provider_model_sources(); + group.throughput(Throughput::Elements( + (provider_count + dataset.provider_models.len()) as u64, + )); + + group.bench_with_input(BenchmarkId::new("batch", name), &dataset, |b, input| { + b.iter(|| { + construct_batch( + black_box(&input.providers), + black_box(&provider_model_sources), + ) + }) + }); + } + + group.finish(); +} + +fn construct_batch(providers: &[ProviderSource], provider_models: &[ProviderModelSource<'_>]) { + let catalog = ModelCatalog::build(providers, provider_models).expect("batch build"); + + black_box(( + catalog.provider_count(), + catalog.provider_model_count(), + catalog.model_config_count(), + )); +} + fn make_dataset(provider_count: usize, model_count: usize, with_env_vars: bool) -> Dataset { debug_assert!(provider_count > 0); @@ -89,42 +129,3 @@ fn make_dataset(provider_count: usize, model_count: usize, with_env_vars: bool) provider_models, } } - -fn construct_batch(providers: &[ProviderSource], provider_models: &[ProviderModelSource<'_>]) { - let catalog = ModelCatalog::build(providers, provider_models).expect("batch build"); - - black_box(( - catalog.provider_count(), - catalog.provider_model_count(), - catalog.model_config_count(), - )); -} - -fn benchmark_builder_construction(c: &mut Criterion) { - let mut group = c.benchmark_group("model_catalog_builder_construct"); - - for (name, provider_count, model_count, with_env_vars) in [ - ("models_dev_snapshot", 96usize, 3031usize, true), - ("max", 16384usize, 65535usize, false), - ] { - let dataset = make_dataset(provider_count, model_count, with_env_vars); - let provider_model_sources = dataset.provider_model_sources(); - group.throughput(Throughput::Elements( - (provider_count + dataset.provider_models.len()) as u64, - )); - - group.bench_with_input(BenchmarkId::new("batch", name), &dataset, |b, input| { - b.iter(|| { - construct_batch( - black_box(&input.providers), - black_box(&provider_model_sources), - ) - }) - }); - } - - group.finish(); -} - -criterion_group!(benches, benchmark_builder_construction); -criterion_main!(benches); diff --git a/src/reloaded-code-core/benches/path_resolvers.rs b/src/reloaded-code-core/benches/path_resolvers.rs index 1e670dfb..dd4f4960 100644 --- a/src/reloaded-code-core/benches/path_resolvers.rs +++ b/src/reloaded-code-core/benches/path_resolvers.rs @@ -11,14 +11,14 @@ //! # Test Cases (resolvers) //! //! ```text -//! | Case | Path | What it tests | -//! |------------------------|----------------------------------------------------|------------------------------------------------| -//! | existing_file | src/lib.rs | Fast path: file exists, canonicalize succeeds | -//! | new_file_existing_dir | src/new_file_test.rs | Fast path: parent exists, canonicalize parent | -//! | new_file_missing_dir | src/new_dir/nested/new_file_test.rs | Slow path: soft-canonicalize for non-existent | -//! | policy_reject | benchmarks/new_file_test.rs | Rejection via glob policy after resolution | -//! | deep_nested | src/reloaded-code-core/src/path/.../policy.rs | Longer path, more components to process | -//! | traversal_reject | ../../../outside.txt | Early rejection via lexical escape check | +//! | Case | Path | What it tests | +//! | --------------------- | --------------------------------------------- | --------------------------------------------- | +//! | existing_file | src/lib.rs | Fast path: file exists, canonicalize succeeds | +//! | new_file_existing_dir | src/new_file_test.rs | Fast path: parent exists, canonicalize parent | +//! | new_file_missing_dir | src/new_dir/nested/new_file_test.rs | Slow path: soft-canonicalize for non-existent | +//! | policy_reject | benchmarks/new_file_test.rs | Rejection via glob policy after resolution | +//! | deep_nested | src/reloaded-code-core/src/path/.../policy.rs | Longer path, more components to process | +//! | traversal_reject | ../../../outside.txt | Early rejection via lexical escape check | //! ``` //! //! # Reference Results (Linux, optimized build) @@ -75,6 +75,15 @@ //! cargo bench -p reloaded-code-core --bench path_resolvers -- --baseline main //! ``` +criterion_group!( + benches, + bench_resolvers_same_paths, + bench_multiple_bases, + bench_canonicalize_vs_soft +); + +criterion_main!(benches); + use core::hint::black_box; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use reloaded_code_core::path::{ @@ -84,108 +93,53 @@ use soft_canonicalize::soft_canonicalize; use std::fs; use tempfile::TempDir; +const DEEP_NESTED: &str = "src/reloaded-code-core/src/path/allowed_glob/policy.rs"; const EXISTING_FILE: &str = "src/lib.rs"; const NEW_FILE_EXISTING_DIR: &str = "src/new_file_test.rs"; // Path that matches simple policy (src/**/*.rs) but has missing directories const NEW_FILE_MISSING_DIR: &str = "src/new_dir/nested/new_file_test.rs"; // Path that does NOT match simple policy - tests early rejection const POLICY_REJECT: &str = "benchmarks/new_file_test.rs"; -const DEEP_NESTED: &str = "src/reloaded-code-core/src/path/allowed_glob/policy.rs"; const TRAVERSAL: &str = "../../../outside.txt"; -fn build_policy(f: F) -> reloaded_code_core::error::ToolResult -where - F: FnOnce(GlobPolicyBuilder) -> reloaded_code_core::error::ToolResult, -{ - let base = soft_canonicalize(std::env::current_dir().unwrap()).unwrap(); - f(GlobPolicy::builder_with_base(&base)?).and_then(|b| b.build()) -} - -/// Benchmarks [`AllowedPathResolver`] and [`AllowedGlobResolver`] on the same paths. -/// -/// This group measures the core resolve operation under different conditions. -/// -/// # Resolvers Compared +/// Benchmarks `std::fs::canonicalize` vs `soft_canonicalize` directly. /// -/// ```text -/// | Resolver | Description | -/// |---------------------------------|------------------------------------------| -/// | AllowedPathResolver | Baseline: no glob policy | -/// | AllowedGlobResolver_simple | Single rule: src/**/*.rs | -/// | AllowedGlobResolver_complex | 10 rules: realistic project config | -/// ``` +/// Isolates the core filesystem operation to understand where time is spent. /// -/// # Expected Performance (Unix) +/// # Test Cases /// /// ```text -/// | Case | Expected Time | Why | -/// |------------------------|---------------|----------------------------------------| -/// | existing_file | 1-2 µs | canonicalize is fast for existing | -/// | new_file_existing_dir | 3-4 µs | canonicalize parent, join filename | -/// | new_file_missing_dir | 7-8 µs | soft-canonicalize walks filesystem | -/// | deep_nested | 10-11 µs | more path components to process | -/// | traversal_reject | ~20 ns | lexical check only, no filesystem I/O | +/// | Case | Path | canonicalize | soft_canonicalize | +/// | ---------------- | ---------------------------- | ------------ | ----------------- | +/// | existing_file | src/lib.rs (exists) | O(1) FS call | O(1) FS call | +/// | new_file_shallow | new_file.rs (in root) | N/A | O(1) FS call | +/// | new_file_deep | a/b/c/new_file.rs (3 levels) | N/A | O(4) FS calls | /// ``` -fn bench_resolvers_same_paths(c: &mut Criterion) { - let mut group = c.benchmark_group("resolvers"); +fn bench_canonicalize_vs_soft(c: &mut Criterion) { + let mut group = c.benchmark_group("canonicalize"); let current_dir = std::env::current_dir().unwrap(); - - // Baseline: AllowedPathResolver (no glob policy) - let allowed = AllowedPathResolver::new(vec![current_dir.clone()]).unwrap(); - - // Simple policy: single glob pattern (src/**/*.rs) - let simple_policy = build_policy(|b| b.allow("src/**/*.rs")).unwrap(); - - // Complex policy: 10 rules simulating a realistic project configuration. - let complex_policy = build_policy(|b| { - b.allow("src/**")? - .deny("target/**")? - .allow("*.toml")? - .deny("*.log")? - .allow("benches/**")? - .deny("**/test_data/**")? - .allow("tests/**/*.rs")? - .deny("node_modules/**")? - .allow("examples/**") - }) - .unwrap(); - - let glob_simple = AllowedGlobResolver::new(¤t_dir) - .unwrap() - .with_policy(simple_policy); - let glob_complex = AllowedGlobResolver::new(¤t_dir) - .unwrap() - .with_policy(complex_policy); + let existing = current_dir.join("src/lib.rs"); + let new_shallow = current_dir.join("new_file.rs"); + let new_deep = current_dir.join("a/b/c/new_file.rs"); group.throughput(Throughput::Elements(1)); - for (case_name, path_input) in [ - ("existing_file", EXISTING_FILE), - ("new_file_existing_dir", NEW_FILE_EXISTING_DIR), - ("new_file_missing_dir", NEW_FILE_MISSING_DIR), - ("policy_reject", POLICY_REJECT), - ("deep_nested", DEEP_NESTED), - ("traversal_reject", TRAVERSAL), - ] { - group.bench_with_input( - BenchmarkId::new("AllowedPathResolver", case_name), - &allowed, - |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), - ); + group.bench_function("existing_file_canonicalize", |b| { + b.iter(|| existing.canonicalize().unwrap()) + }); - group.bench_with_input( - BenchmarkId::new("AllowedGlobResolver_simple_policy", case_name), - &glob_simple, - |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), - ); + group.bench_function("existing_file_soft_canonicalize", |b| { + b.iter(|| soft_canonicalize(&existing).unwrap()) + }); - group.bench_with_input( - BenchmarkId::new("AllowedGlobResolver_complex_policy", case_name), - &glob_complex, - |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), - ); - } + group.bench_function("new_file_shallow_soft_canonicalize", |b| { + b.iter(|| soft_canonicalize(&new_shallow).unwrap()) + }); + + group.bench_function("new_file_deep_soft_canonicalize", |b| { + b.iter(|| soft_canonicalize(&new_deep).unwrap()) + }); group.finish(); } @@ -198,21 +152,21 @@ fn bench_resolvers_same_paths(c: &mut Criterion) { /// # Setup /// /// ```text -/// | Base | Directory | Contains | -/// |---------|-------------------|--------------| -/// | Base 1 | Current workspace | src/lib.rs | -/// | Base 2 | Temp directory 1 | file1.txt | -/// | Base 3 | Temp directory 2 | file2.txt | +/// | Base | Directory | Contains | +/// | ------ | ----------------- | ---------- | +/// | Base 1 | Current workspace | src/lib.rs | +/// | Base 2 | Temp directory 1 | file1.txt | +/// | Base 3 | Temp directory 2 | file2.txt | /// ``` /// /// # Test Cases /// /// ```text /// | Case | What it tests | -/// |-------------|--------------------------------------------| -/// | first_base | Path found in first base (fastest) | -/// | second_base | Path found in second base (one miss, hit) | -/// | third_base | Path found in third base (two misses, hit) | +/// | ----------- | ------------------------------------------ | +/// | first_base | Path found in first base (fastest) | +/// | second_base | Path found in second base (one miss, hit) | +/// | third_base | Path found in third base (two misses, hit) | /// | not_found | Path not in any base (all bases tried) | /// ``` fn bench_multiple_bases(c: &mut Criterion) { @@ -248,53 +202,99 @@ fn bench_multiple_bases(c: &mut Criterion) { group.finish(); } -/// Benchmarks `std::fs::canonicalize` vs `soft_canonicalize` directly. +/// Benchmarks [`AllowedPathResolver`] and [`AllowedGlobResolver`] on the same paths. /// -/// Isolates the core filesystem operation to understand where time is spent. +/// This group measures the core resolve operation under different conditions. /// -/// # Test Cases +/// # Resolvers Compared /// /// ```text -/// | Case | Path | canonicalize | soft_canonicalize | -/// |-------------------|-------------------------------|--------------|-------------------| -/// | existing_file | src/lib.rs (exists) | O(1) FS call | O(1) FS call | -/// | new_file_shallow | new_file.rs (in root) | N/A | O(1) FS call | -/// | new_file_deep | a/b/c/new_file.rs (3 levels) | N/A | O(4) FS calls | +/// | Resolver | Description | +/// | --------------------------- | ---------------------------------- | +/// | AllowedPathResolver | Baseline: no glob policy | +/// | AllowedGlobResolver_simple | Single rule: src/**/*.rs | +/// | AllowedGlobResolver_complex | 10 rules: realistic project config | /// ``` -fn bench_canonicalize_vs_soft(c: &mut Criterion) { - let mut group = c.benchmark_group("canonicalize"); +/// +/// # Expected Performance (Unix) +/// +/// ```text +/// | Case | Expected Time | Why | +/// | --------------------- | ------------- | ------------------------------------- | +/// | existing_file | 1-2 µs | canonicalize is fast for existing | +/// | new_file_existing_dir | 3-4 µs | canonicalize parent, join filename | +/// | new_file_missing_dir | 7-8 µs | soft-canonicalize walks filesystem | +/// | deep_nested | 10-11 µs | more path components to process | +/// | traversal_reject | ~20 ns | lexical check only, no filesystem I/O | +/// ``` +fn bench_resolvers_same_paths(c: &mut Criterion) { + let mut group = c.benchmark_group("resolvers"); let current_dir = std::env::current_dir().unwrap(); - let existing = current_dir.join("src/lib.rs"); - let new_shallow = current_dir.join("new_file.rs"); - let new_deep = current_dir.join("a/b/c/new_file.rs"); - group.throughput(Throughput::Elements(1)); + // Baseline: AllowedPathResolver (no glob policy) + let allowed = AllowedPathResolver::new(vec![current_dir.clone()]).unwrap(); - group.bench_function("existing_file_canonicalize", |b| { - b.iter(|| existing.canonicalize().unwrap()) - }); + // Simple policy: single glob pattern (src/**/*.rs) + let simple_policy = build_policy(|b| b.allow("src/**/*.rs")).unwrap(); - group.bench_function("existing_file_soft_canonicalize", |b| { - b.iter(|| soft_canonicalize(&existing).unwrap()) - }); + // Complex policy: 10 rules simulating a realistic project configuration. + let complex_policy = build_policy(|b| { + b.allow("src/**")? + .deny("target/**")? + .allow("*.toml")? + .deny("*.log")? + .allow("benches/**")? + .deny("**/test_data/**")? + .allow("tests/**/*.rs")? + .deny("node_modules/**")? + .allow("examples/**") + }) + .unwrap(); - group.bench_function("new_file_shallow_soft_canonicalize", |b| { - b.iter(|| soft_canonicalize(&new_shallow).unwrap()) - }); + let glob_simple = AllowedGlobResolver::new(¤t_dir) + .unwrap() + .with_policy(simple_policy); + let glob_complex = AllowedGlobResolver::new(¤t_dir) + .unwrap() + .with_policy(complex_policy); - group.bench_function("new_file_deep_soft_canonicalize", |b| { - b.iter(|| soft_canonicalize(&new_deep).unwrap()) - }); + group.throughput(Throughput::Elements(1)); + + for (case_name, path_input) in [ + ("existing_file", EXISTING_FILE), + ("new_file_existing_dir", NEW_FILE_EXISTING_DIR), + ("new_file_missing_dir", NEW_FILE_MISSING_DIR), + ("policy_reject", POLICY_REJECT), + ("deep_nested", DEEP_NESTED), + ("traversal_reject", TRAVERSAL), + ] { + group.bench_with_input( + BenchmarkId::new("AllowedPathResolver", case_name), + &allowed, + |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), + ); + + group.bench_with_input( + BenchmarkId::new("AllowedGlobResolver_simple_policy", case_name), + &glob_simple, + |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), + ); + + group.bench_with_input( + BenchmarkId::new("AllowedGlobResolver_complex_policy", case_name), + &glob_complex, + |b, resolver| b.iter(|| resolver.resolve(black_box(path_input))), + ); + } group.finish(); } -criterion_group!( - benches, - bench_resolvers_same_paths, - bench_multiple_bases, - bench_canonicalize_vs_soft -); - -criterion_main!(benches); +fn build_policy(f: F) -> reloaded_code_core::error::ToolResult +where + F: FnOnce(GlobPolicyBuilder) -> reloaded_code_core::error::ToolResult, +{ + let base = soft_canonicalize(std::env::current_dir().unwrap()).unwrap(); + f(GlobPolicy::builder_with_base(&base)?).and_then(|b| b.build()) +} diff --git a/src/reloaded-code-core/benches/permissions.rs b/src/reloaded-code-core/benches/permissions.rs index e2fdd72c..80cc6022 100644 --- a/src/reloaded-code-core/benches/permissions.rs +++ b/src/reloaded-code-core/benches/permissions.rs @@ -5,6 +5,10 @@ //! Cases cover exact matches, wildcard permission keys, wildcard subject //! patterns, and longer rulesets where the winning rule is near the end. +criterion_group!(benches, bench_ruleset_evaluate, bench_check_permission); + +criterion_main!(benches); + use core::hint::black_box; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use reloaded_code_core::permissions::{PermissionAction, Rule, Ruleset}; @@ -21,6 +25,49 @@ struct PermissionCase { ruleset: Ruleset, } +/// Benchmark [`OptionRulesetExt::check`] (ruleset lookup plus optional default +/// fallthrough) across all [`benchmark_cases`]. +fn bench_check_permission(c: &mut Criterion) { + let mut group = c.benchmark_group("permissions/check_permission"); + let cases = benchmark_cases(); + + group.throughput(Throughput::Elements(1)); + + for case in &cases { + group.bench_with_input(BenchmarkId::new("ruleset", case.name), case, |b, case| { + b.iter(|| { + Some(black_box(&case.ruleset)) + .check(black_box(case.tool_name), black_box(case.subject)) + .expect("benchmark fixture should be allowed"); + black_box(()) + }) + }); + } + + group.finish(); +} + +/// Benchmark [`Ruleset::evaluate`] across all [`benchmark_cases`]. +fn bench_ruleset_evaluate(c: &mut Criterion) { + let mut group = c.benchmark_group("permissions/evaluate"); + let cases = benchmark_cases(); + + group.throughput(Throughput::Elements(1)); + + for case in &cases { + group.bench_with_input(BenchmarkId::new("ruleset", case.name), case, |b, case| { + b.iter(|| { + black_box( + case.ruleset + .evaluate(black_box(case.tool_name), black_box(case.subject)), + ) + }) + }); + } + + group.finish(); +} + /// Build a [`Ruleset`] with `rule_count - 1` deny rules followed by one /// allow rule, so that the winning rule is always the last entry. /// @@ -104,49 +151,3 @@ fn benchmark_cases() -> Vec { }, ] } - -/// Benchmark [`Ruleset::evaluate`] across all [`benchmark_cases`]. -fn bench_ruleset_evaluate(c: &mut Criterion) { - let mut group = c.benchmark_group("permissions/evaluate"); - let cases = benchmark_cases(); - - group.throughput(Throughput::Elements(1)); - - for case in &cases { - group.bench_with_input(BenchmarkId::new("ruleset", case.name), case, |b, case| { - b.iter(|| { - black_box( - case.ruleset - .evaluate(black_box(case.tool_name), black_box(case.subject)), - ) - }) - }); - } - - group.finish(); -} - -/// Benchmark [`OptionRulesetExt::check`] (ruleset lookup plus optional default -/// fallthrough) across all [`benchmark_cases`]. -fn bench_check_permission(c: &mut Criterion) { - let mut group = c.benchmark_group("permissions/check_permission"); - let cases = benchmark_cases(); - - group.throughput(Throughput::Elements(1)); - - for case in &cases { - group.bench_with_input(BenchmarkId::new("ruleset", case.name), case, |b, case| { - b.iter(|| { - Some(black_box(&case.ruleset)) - .check(black_box(case.tool_name), black_box(case.subject)) - .expect("benchmark fixture should be allowed"); - black_box(()) - }) - }); - } - - group.finish(); -} - -criterion_group!(benches, bench_ruleset_evaluate, bench_check_permission); -criterion_main!(benches); diff --git a/src/reloaded-code-core/benches/tools_edit.rs b/src/reloaded-code-core/benches/tools_edit.rs index 4739980c..c03f0164 100644 --- a/src/reloaded-code-core/benches/tools_edit.rs +++ b/src/reloaded-code-core/benches/tools_edit.rs @@ -10,13 +10,13 @@ //! # Test Cases //! //! ```text -//! | Case | Source | Occurrences | replace_all | What it tests | -//! |------------------|-----------|-------------|-------------|------------------------------------| -//! | rename_string | corpus S | 1 | false | Rename a string literal | -//! | change_type | corpus M | 1 | false | Change a type annotation | -//! | update_constant | corpus L | 1 | false | Update a numeric constant | -//! | replace_all | corpus L | many | true | Bulk rename across many occurrences| -//! | not_found | corpus M | 0 | false | Error path: string not found | +//! | Case | Source | Occurrences | replace_all | What it tests | +//! | --------------- | -------- | ----------- | ----------- | ----------------------------------- | +//! | rename_string | corpus S | 1 | false | Rename a string literal | +//! | change_type | corpus M | 1 | false | Change a type annotation | +//! | update_constant | corpus L | 1 | false | Update a numeric constant | +//! | replace_all | corpus L | many | true | Bulk rename across many occurrences | +//! | not_found | corpus M | 0 | false | Error path: string not found | //! ``` //! //! # Running Benchmarks @@ -26,8 +26,9 @@ //! cargo bench -p reloaded-code-core --no-default-features --features blocking --bench tools_edit -- --sample-size 10 --measurement-time 1 --warm-up-time 1 //! ``` -#[path = "common/mod.rs"] -mod common; +criterion_group!(benches, bench_edit_file); + +criterion_main!(benches); use common::corpus_content; use common::CorpusSize; @@ -37,12 +38,8 @@ use reloaded_code_core::path::AbsolutePathResolver; use reloaded_code_core::tools::{edit_file, EditRequest, EditSettings}; use tempfile::TempDir; -fn create_temp_file(content: &str) -> (TempDir, String) { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test_input.rs"); - std::fs::write(&file_path, content).unwrap(); - (temp_dir, file_path.to_str().unwrap().to_owned()) -} +#[path = "common/mod.rs"] +mod common; fn bench_edit_file(c: &mut Criterion) { let mut group = c.benchmark_group("edit_file"); @@ -177,5 +174,9 @@ fn bench_edit_file(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_edit_file); -criterion_main!(benches); +fn create_temp_file(content: &str) -> (TempDir, String) { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_input.rs"); + std::fs::write(&file_path, content).unwrap(); + (temp_dir, file_path.to_str().unwrap().to_owned()) +} diff --git a/src/reloaded-code-core/benches/tools_glob.rs b/src/reloaded-code-core/benches/tools_glob.rs index cf023c93..d808cc51 100644 --- a/src/reloaded-code-core/benches/tools_glob.rs +++ b/src/reloaded-code-core/benches/tools_glob.rs @@ -10,12 +10,12 @@ //! # Test Cases //! //! ```text -//! | Case | Files | Pattern | Matches | What it tests | -//! |--------------|-------|--------------|---------|---------------------------------------| -//! | small_tree | 8 | **/*.rs | 5 | Small project, fast walk | -//! | large_tree | 300 | **/*.rs | 150 | Large monorepo, many matches | -//! | no_matches | 300 | *.xyz | 0 | Walk with no matches, full traversal | -//! | deep_nesting | 10 | **/*.rs | 10 | Deep directory nesting, path handling | +//! | Case | Files | Pattern | Matches | What it tests | +//! | ------------ | ----- | ------- | ------- | ------------------------------------- | +//! | small_tree | 8 | **/*.rs | 5 | Small project, fast walk | +//! | large_tree | 300 | **/*.rs | 150 | Large monorepo, many matches | +//! | no_matches | 300 | *.xyz | 0 | Walk with no matches, full traversal | +//! | deep_nesting | 10 | **/*.rs | 10 | Deep directory nesting, path handling | //! ``` //! //! # Running Benchmarks @@ -25,8 +25,9 @@ //! cargo bench -p reloaded-code-core --bench tools_glob -- --sample-size 10 --measurement-time 1 --warm-up-time 1 //! ``` -#[path = "common/mod.rs"] -mod common; +criterion_group!(benches, bench_glob_files); + +criterion_main!(benches); use common::corpus_content; use common::CorpusSize; @@ -37,6 +38,9 @@ use reloaded_code_core::tools::{glob_files, GlobRequest, GlobSettings}; use std::fs; use tempfile::TempDir; +#[path = "common/mod.rs"] +mod common; + /// Temporary directory fixture for benchmark tests. /// /// Holds a temporary directory and its metadata for use in glob performance tests. @@ -51,44 +55,78 @@ struct TreeFixture { file_count: usize, } -/// Creates a small test fixture with typical Rust project structure. +/// Benchmarks [`glob_files`] performance across different tree shapes. /// -/// Layout: +/// Tests four scenarios: small tree (8 files), large tree (300 files), +/// no-match traversal, and deep nesting (10 levels). Each case measures +/// glob matching throughput using [`AbsolutePathResolver`]. +fn bench_glob_files(c: &mut Criterion) { + let mut group = c.benchmark_group("glob_files"); + + let small = create_small_tree(); + let large = create_large_tree(); + let deep = create_deep_nesting_tree(); + + let resolver = AbsolutePathResolver; + let settings = GlobSettings::new().with_limit(1000).unwrap(); + + let cases: Vec<(&str, &str, &str, usize)> = vec![ + ("small_tree", &small.path, "**/*.rs", small.file_count), + ("large_tree", &large.path, "**/*.rs", large.file_count), + ("no_matches", &large.path, "*.xyz", large.file_count), + ("deep_nesting", &deep.path, "**/*.rs", deep.file_count), + ]; + + for (case_name, path, pattern, file_count) in &cases { + group.throughput(Throughput::Elements(*file_count as u64)); + group.bench_with_input( + BenchmarkId::new("AbsolutePathResolver", *case_name), + &(*path, *pattern), + |b, &(path, pattern)| { + b.iter(|| { + black_box(glob_files( + black_box(&resolver), + GlobRequest { + pattern: pattern.to_string(), + path: path.to_string(), + }, + black_box(&settings), + )) + }) + }, + ); + } + + group.finish(); +} + +/// Creates a deeply nested test fixture for path handling stress tests. +/// +/// Layout (10 files): /// ```text -/// src/lib.rs, src/main.rs, src/utils/mod.rs -/// tests/integration.rs -/// benches/bench_main.rs -/// Cargo.toml, README.md, .gitignore +/// level_0/data.rs +/// level_0/level_1/data.rs +/// level_0/.../level_9/data.rs /// ``` -fn create_small_tree() -> TreeFixture { +fn create_deep_nesting_tree() -> TreeFixture { let temp_dir = TempDir::new().unwrap(); let base = temp_dir.path(); - let dirs = ["src", "src/utils", "tests", "benches"]; - for dir in &dirs { - fs::create_dir_all(base.join(dir)).unwrap(); - } - - let files = [ - "src/lib.rs", - "src/utils/mod.rs", - "src/main.rs", - "tests/integration.rs", - "benches/bench_main.rs", - "Cargo.toml", - "README.md", - ".gitignore", - ]; + let mut current = base.to_path_buf(); + let mut count = 0; - for file in &files { - let content = corpus_content(CorpusSize::Small); - fs::write(base.join(file), content).unwrap(); + // Create 10 nested levels, each with a data.rs file + for level in 0..10 { + current = current.join(format!("level_{level}")); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("data.rs"), corpus_content(CorpusSize::Small)).unwrap(); + count += 1; } TreeFixture { path: base.to_str().unwrap().to_owned(), temp_dir, - file_count: files.len(), + file_count: count, } } @@ -140,80 +178,43 @@ fn create_large_tree() -> TreeFixture { } } -/// Creates a deeply nested test fixture for path handling stress tests. +/// Creates a small test fixture with typical Rust project structure. /// -/// Layout (10 files): +/// Layout: /// ```text -/// level_0/data.rs -/// level_0/level_1/data.rs -/// level_0/.../level_9/data.rs +/// src/lib.rs, src/main.rs, src/utils/mod.rs +/// tests/integration.rs +/// benches/bench_main.rs +/// Cargo.toml, README.md, .gitignore /// ``` -fn create_deep_nesting_tree() -> TreeFixture { +fn create_small_tree() -> TreeFixture { let temp_dir = TempDir::new().unwrap(); let base = temp_dir.path(); - let mut current = base.to_path_buf(); - let mut count = 0; + let dirs = ["src", "src/utils", "tests", "benches"]; + for dir in &dirs { + fs::create_dir_all(base.join(dir)).unwrap(); + } - // Create 10 nested levels, each with a data.rs file - for level in 0..10 { - current = current.join(format!("level_{level}")); - fs::create_dir_all(¤t).unwrap(); - fs::write(current.join("data.rs"), corpus_content(CorpusSize::Small)).unwrap(); - count += 1; + let files = [ + "src/lib.rs", + "src/utils/mod.rs", + "src/main.rs", + "tests/integration.rs", + "benches/bench_main.rs", + "Cargo.toml", + "README.md", + ".gitignore", + ]; + + for file in &files { + let content = corpus_content(CorpusSize::Small); + fs::write(base.join(file), content).unwrap(); } TreeFixture { path: base.to_str().unwrap().to_owned(), temp_dir, - file_count: count, - } -} - -/// Benchmarks [`glob_files`] performance across different tree shapes. -/// -/// Tests four scenarios: small tree (8 files), large tree (300 files), -/// no-match traversal, and deep nesting (10 levels). Each case measures -/// glob matching throughput using [`AbsolutePathResolver`]. -fn bench_glob_files(c: &mut Criterion) { - let mut group = c.benchmark_group("glob_files"); - - let small = create_small_tree(); - let large = create_large_tree(); - let deep = create_deep_nesting_tree(); - - let resolver = AbsolutePathResolver; - let settings = GlobSettings::new().with_limit(1000).unwrap(); - - let cases: Vec<(&str, &str, &str, usize)> = vec![ - ("small_tree", &small.path, "**/*.rs", small.file_count), - ("large_tree", &large.path, "**/*.rs", large.file_count), - ("no_matches", &large.path, "*.xyz", large.file_count), - ("deep_nesting", &deep.path, "**/*.rs", deep.file_count), - ]; - - for (case_name, path, pattern, file_count) in &cases { - group.throughput(Throughput::Elements(*file_count as u64)); - group.bench_with_input( - BenchmarkId::new("AbsolutePathResolver", *case_name), - &(*path, *pattern), - |b, &(path, pattern)| { - b.iter(|| { - black_box(glob_files( - black_box(&resolver), - GlobRequest { - pattern: pattern.to_string(), - path: path.to_string(), - }, - black_box(&settings), - )) - }) - }, - ); + file_count: files.len(), } - - group.finish(); } - -criterion_group!(benches, bench_glob_files); -criterion_main!(benches); diff --git a/src/reloaded-code-core/benches/tools_grep.rs b/src/reloaded-code-core/benches/tools_grep.rs index c1ce7333..65c23ab2 100644 --- a/src/reloaded-code-core/benches/tools_grep.rs +++ b/src/reloaded-code-core/benches/tools_grep.rs @@ -12,13 +12,13 @@ //! # Test Cases //! //! ```text -//! | Case | Files | Pattern | What it tests | -//! |-----------------|-------|---------------|----------------------------------| -//! | single_file | 1 | fn | Single file, many matches | -//! | multi_file | 10 | fn | Multi-file, moderate matches | -//! | no_matches | 10 | xyznonexistent| No matches, fast rejection | -//! | regex_pattern | 10 | fn\s+\w+ | Complex regex matching | -//! | large_tree | 30 | fn | Large directory tree traversal | +//! | Case | Files | Pattern | What it tests | +//! | ------------- | ----- | -------------- | ------------------------------ | +//! | single_file | 1 | fn | Single file, many matches | +//! | multi_file | 10 | fn | Multi-file, moderate matches | +//! | no_matches | 10 | xyznonexistent | No matches, fast rejection | +//! | regex_pattern | 10 | fn\s+\w+ | Complex regex matching | +//! | large_tree | 30 | fn | Large directory tree traversal | //! ``` //! //! # Running Benchmarks @@ -28,8 +28,9 @@ //! cargo bench -p reloaded-code-core --bench tools_grep -- --sample-size 10 --measurement-time 1 --warm-up-time 1 //! ``` -#[path = "common/mod.rs"] -mod common; +criterion_group!(benches, bench_grep_search, bench_grep_format); + +criterion_main!(benches); use common::{corpus_content, CorpusSize}; use core::hint::black_box; @@ -39,6 +40,9 @@ use reloaded_code_core::tools::{grep_search, GrepFormattingSettings, GrepRequest use std::fs; use tempfile::TempDir; +#[path = "common/mod.rs"] +mod common; + /// Holds a test directory with precomputed match counts for benchmarking. struct TestDir { #[allow(dead_code)] // Used to keep temp dir alive (prevent drop) @@ -47,94 +51,37 @@ struct TestDir { total_matches: usize, } -/// Creates a single-file test fixture for benchmarking single-file grep performance. -/// -/// Layout: -/// ```text -/// plan.rs (small corpus) -/// ``` -fn create_single_file() -> TestDir { - let temp_dir = TempDir::new().unwrap(); - let content = corpus_content(CorpusSize::Small); - fs::write(temp_dir.path().join("plan.rs"), content).unwrap(); - let matches = content.lines().filter(|l| l.contains("fn ")).count(); - TestDir { - path: temp_dir.path().to_str().unwrap().to_owned(), - temp_dir, - total_matches: matches, - } -} - -/// Creates a test fixture with 10 Rust files cycling through corpus sizes. -/// -/// Layout: -/// ```text -/// {prefix}0.rs (small corpus) -/// {prefix}1.rs (medium corpus) -/// {prefix}2.rs (large corpus) -/// ... (10 files total, cycling small/medium/large) -/// ``` -fn create_test_files(prefix: &str) -> TestDir { - let temp_dir = TempDir::new().unwrap(); - let mut total_matches = 0; - for (i, size) in [CorpusSize::Small, CorpusSize::Medium, CorpusSize::Large] - .iter() - .cycle() - .enumerate() - .take(10) - { - let content = corpus_content(*size); - let name = format!("{prefix}{i}.rs"); - fs::write(temp_dir.path().join(name), content).unwrap(); - total_matches += content.lines().filter(|l| l.contains("fn ")).count(); - } - TestDir { - path: temp_dir.path().to_str().unwrap().to_owned(), - temp_dir, - total_matches, - } -} +/// Benchmarks formatting of precomputed search results (isolates formatting overhead from search). +fn bench_grep_format(c: &mut Criterion) { + let mut group = c.benchmark_group("grep_format"); -fn create_multi_file() -> TestDir { - create_test_files("file_") -} + let large = create_large_tree(); + let resolver = AbsolutePathResolver; + let settings = GrepSettings::new().with_max_limit(1000).unwrap(); -fn create_no_matches() -> TestDir { - let mut dir = create_test_files("nomatch_"); - dir.total_matches = 0; - dir -} + let search_result = grep_search( + &resolver, + GrepRequest { + pattern: "fn ".to_string(), + path: large.path.clone(), + include: None, + limit: None, + }, + &settings, + ) + .unwrap(); -fn create_regex_pattern() -> TestDir { - create_test_files("src_") -} + group.throughput(Throughput::Elements(search_result.match_count as u64)); + group.bench_function("with_line_numbers", |b| { + b.iter(|| black_box(search_result.format(GrepFormattingSettings::new()))) + }); + group.bench_function("without_line_numbers", |b| { + b.iter(|| { + black_box(search_result.format(GrepFormattingSettings::new().with_line_numbers(false))) + }) + }); -/// Creates a test fixture with nested directories for benchmarking large tree traversal. -/// -/// Layout: -/// ```text -/// src/module_00/mod_0.rs (small corpus) -/// src/module_01/mod_1.rs (medium corpus) -/// src/module_02/mod_2.rs (large corpus) -/// ... (30 modules total, cycling small/medium/large) -/// ``` -fn create_large_tree() -> TestDir { - let temp_dir = TempDir::new().unwrap(); - let mut total_matches = 0; - let sizes = [CorpusSize::Small, CorpusSize::Medium, CorpusSize::Large]; - for i in 0..30 { - let size = sizes[i % 3]; - let content = corpus_content(size); - let dir = temp_dir.path().join(format!("src/module_{i:02x}")); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join(format!("mod_{i}.rs")), content).unwrap(); - total_matches += content.lines().filter(|l| l.contains("fn ")).count(); - } - TestDir { - path: temp_dir.path().to_str().unwrap().to_owned(), - temp_dir, - total_matches, - } + group.finish(); } /// Benchmarks `grep_search` with formatting across different test cases. @@ -231,38 +178,92 @@ fn bench_grep_search(c: &mut Criterion) { group.finish(); } -/// Benchmarks formatting of precomputed search results (isolates formatting overhead from search). -fn bench_grep_format(c: &mut Criterion) { - let mut group = c.benchmark_group("grep_format"); +/// Creates a test fixture with nested directories for benchmarking large tree traversal. +/// +/// Layout: +/// ```text +/// src/module_00/mod_0.rs (small corpus) +/// src/module_01/mod_1.rs (medium corpus) +/// src/module_02/mod_2.rs (large corpus) +/// ... (30 modules total, cycling small/medium/large) +/// ``` +fn create_large_tree() -> TestDir { + let temp_dir = TempDir::new().unwrap(); + let mut total_matches = 0; + let sizes = [CorpusSize::Small, CorpusSize::Medium, CorpusSize::Large]; + for i in 0..30 { + let size = sizes[i % 3]; + let content = corpus_content(size); + let dir = temp_dir.path().join(format!("src/module_{i:02x}")); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(format!("mod_{i}.rs")), content).unwrap(); + total_matches += content.lines().filter(|l| l.contains("fn ")).count(); + } + TestDir { + path: temp_dir.path().to_str().unwrap().to_owned(), + temp_dir, + total_matches, + } +} - let large = create_large_tree(); - let resolver = AbsolutePathResolver; - let settings = GrepSettings::new().with_max_limit(1000).unwrap(); +fn create_multi_file() -> TestDir { + create_test_files("file_") +} - let search_result = grep_search( - &resolver, - GrepRequest { - pattern: "fn ".to_string(), - path: large.path.clone(), - include: None, - limit: None, - }, - &settings, - ) - .unwrap(); +fn create_no_matches() -> TestDir { + let mut dir = create_test_files("nomatch_"); + dir.total_matches = 0; + dir +} - group.throughput(Throughput::Elements(search_result.match_count as u64)); - group.bench_function("with_line_numbers", |b| { - b.iter(|| black_box(search_result.format(GrepFormattingSettings::new()))) - }); - group.bench_function("without_line_numbers", |b| { - b.iter(|| { - black_box(search_result.format(GrepFormattingSettings::new().with_line_numbers(false))) - }) - }); +fn create_regex_pattern() -> TestDir { + create_test_files("src_") +} - group.finish(); +/// Creates a single-file test fixture for benchmarking single-file grep performance. +/// +/// Layout: +/// ```text +/// plan.rs (small corpus) +/// ``` +fn create_single_file() -> TestDir { + let temp_dir = TempDir::new().unwrap(); + let content = corpus_content(CorpusSize::Small); + fs::write(temp_dir.path().join("plan.rs"), content).unwrap(); + let matches = content.lines().filter(|l| l.contains("fn ")).count(); + TestDir { + path: temp_dir.path().to_str().unwrap().to_owned(), + temp_dir, + total_matches: matches, + } } -criterion_group!(benches, bench_grep_search, bench_grep_format); -criterion_main!(benches); +/// Creates a test fixture with 10 Rust files cycling through corpus sizes. +/// +/// Layout: +/// ```text +/// {prefix}0.rs (small corpus) +/// {prefix}1.rs (medium corpus) +/// {prefix}2.rs (large corpus) +/// ... (10 files total, cycling small/medium/large) +/// ``` +fn create_test_files(prefix: &str) -> TestDir { + let temp_dir = TempDir::new().unwrap(); + let mut total_matches = 0; + for (i, size) in [CorpusSize::Small, CorpusSize::Medium, CorpusSize::Large] + .iter() + .cycle() + .enumerate() + .take(10) + { + let content = corpus_content(*size); + let name = format!("{prefix}{i}.rs"); + fs::write(temp_dir.path().join(name), content).unwrap(); + total_matches += content.lines().filter(|l| l.contains("fn ")).count(); + } + TestDir { + path: temp_dir.path().to_str().unwrap().to_owned(), + temp_dir, + total_matches, + } +} diff --git a/src/reloaded-code-core/benches/tools_read.rs b/src/reloaded-code-core/benches/tools_read.rs index dcfdf375..b05fe716 100644 --- a/src/reloaded-code-core/benches/tools_read.rs +++ b/src/reloaded-code-core/benches/tools_read.rs @@ -11,13 +11,13 @@ //! # Test Cases (per mode) //! //! ```text -//! | Case | Source | What it tests | -//! |---------------|-----------|-----------------------------------| -//! | small_file | corpus S | Small file, fits in one read | -//! | medium_file | corpus M | Medium file, buffered reads | -//! | large_file | corpus L | Large file, many lines processed | -//! | offset_read | corpus M | Offset + limit, partial read | -//! | crlf_file | corpus M | CRLF stripping overhead | +//! | Case | Source | What it tests | +//! | ----------- | -------- | -------------------------------- | +//! | small_file | corpus S | Small file, fits in one read | +//! | medium_file | corpus M | Medium file, buffered reads | +//! | large_file | corpus L | Large file, many lines processed | +//! | offset_read | corpus M | Offset + limit, partial read | +//! | crlf_file | corpus M | CRLF stripping overhead | //! ``` //! //! # Running Benchmarks @@ -27,8 +27,9 @@ //! cargo bench -p reloaded-code-core --no-default-features --features blocking --bench tools_read -- --sample-size 10 --measurement-time 1 --warm-up-time 1 //! ``` -#[path = "common/mod.rs"] -mod common; +criterion_group!(benches, bench_read_file); + +criterion_main!(benches); use common::{corpus_content, corpus_crlf, CorpusSize}; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; @@ -37,6 +38,9 @@ use reloaded_code_core::tools::{read_file, ReadRequest, ReadSettings}; use std::fs; use tempfile::TempDir; +#[path = "common/mod.rs"] +mod common; + /// Holds a temporary test file for benchmarking. /// /// The [`TempDir`] keeps the file alive until the struct is dropped. @@ -50,19 +54,6 @@ struct TestFile { line_count: usize, } -/// Creates a temporary file with the given content for benchmarking. -fn create_test_file(content: &str) -> TestFile { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test_input.rs"); - fs::write(&file_path, content).unwrap(); - let line_count = content.lines().count(); - TestFile { - path: file_path.to_str().unwrap().to_owned(), - temp_dir, - line_count, - } -} - /// Benchmarks `read_file` across file sizes, offsets, line endings, and line-number modes. fn bench_read_file(c: &mut Criterion) { let mut group = c.benchmark_group("read_file"); @@ -193,5 +184,15 @@ fn bench_read_file(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_read_file); -criterion_main!(benches); +/// Creates a temporary file with the given content for benchmarking. +fn create_test_file(content: &str) -> TestFile { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_input.rs"); + fs::write(&file_path, content).unwrap(); + let line_count = content.lines().count(); + TestFile { + path: file_path.to_str().unwrap().to_owned(), + temp_dir, + line_count, + } +} diff --git a/src/reloaded-code-core/benches/tools_write.rs b/src/reloaded-code-core/benches/tools_write.rs index f950bfaf..62241939 100644 --- a/src/reloaded-code-core/benches/tools_write.rs +++ b/src/reloaded-code-core/benches/tools_write.rs @@ -10,12 +10,12 @@ //! # Test Cases //! //! ```text -//! | Case | Content | Path depth | What it tests | -//! |---------------|-----------|------------|---------------------------------------| -//! | small_write | corpus S | 1 | Small write to flat directory | -//! | medium_write | corpus M | 1 | Medium write to flat directory | -//! | large_write | corpus L | 1 | Large write to flat directory | -//! | nested_dirs | corpus S | 4 (a/b/c/) | Write creating nested directories | +//! | Case | Content | Path depth | What it tests | +//! | ------------ | -------- | ---------- | --------------------------------- | +//! | small_write | corpus S | 1 | Small write to flat directory | +//! | medium_write | corpus M | 1 | Medium write to flat directory | +//! | large_write | corpus L | 1 | Large write to flat directory | +//! | nested_dirs | corpus S | 4 (a/b/c/) | Write creating nested directories | //! ``` //! //! # Running Benchmarks @@ -25,8 +25,9 @@ //! cargo bench -p reloaded-code-core --no-default-features --features blocking --bench tools_write -- --sample-size 10 --measurement-time 1 --warm-up-time 1 //! ``` -#[path = "common/mod.rs"] -mod common; +criterion_group!(benches, bench_write_file); + +criterion_main!(benches); use common::corpus_content; use common::CorpusSize; @@ -36,6 +37,9 @@ use reloaded_code_core::path::AbsolutePathResolver; use reloaded_code_core::tools::{write_file, WriteRequest, WriteSettings}; use tempfile::TempDir; +#[path = "common/mod.rs"] +mod common; + fn bench_write_file(c: &mut Criterion) { let mut group = c.benchmark_group("write_file"); @@ -91,6 +95,3 @@ fn bench_write_file(c: &mut Criterion) { group.finish(); } - -criterion_group!(benches, bench_write_file); -criterion_main!(benches); diff --git a/src/reloaded-code-core/examples/system_prompt/build.rs b/src/reloaded-code-core/examples/system_prompt/build.rs index 846de191..afc3a93a 100644 --- a/src/reloaded-code-core/examples/system_prompt/build.rs +++ b/src/reloaded-code-core/examples/system_prompt/build.rs @@ -1,8 +1,7 @@ +use super::{definitions, mock_tools, report, PromptArtifacts, PromptCase}; use reloaded_code_core::context; use reloaded_code_core::{AllowedPathResolver, SystemPromptBuilder}; -use super::{definitions, mock_tools, report, PromptArtifacts, PromptCase}; - /// Renders one example case and its matching tool-definition payload. pub fn build_case(case: PromptCase) -> PromptArtifacts { let system_prompt = build_system_prompt(case); diff --git a/src/reloaded-code-core/examples/system_prompt/definitions.rs b/src/reloaded-code-core/examples/system_prompt/definitions.rs index 32455ed5..480b179f 100644 --- a/src/reloaded-code-core/examples/system_prompt/definitions.rs +++ b/src/reloaded-code-core/examples/system_prompt/definitions.rs @@ -1,11 +1,10 @@ //! Tool-definition builders for system prompt preview examples. +use super::{PromptCase, TaskTarget}; use reloaded_code_core::context::PathMode; use reloaded_code_core::tool_metadata; use serde_json::{json, Map, Value}; -use super::{PromptCase, TaskTarget}; - /// Builds the tool definitions that match one example case. pub(super) fn tool_definitions_for_case(case: PromptCase) -> Vec { let mut definitions = Vec::with_capacity(10); @@ -22,70 +21,69 @@ pub(super) fn tool_definitions_for_case(case: PromptCase) -> Vec { definitions } -fn push_read_definition(definitions: &mut Vec, case: PromptCase) { - let Some(read) = case.read else { - return; - }; +fn boolean_schema(description: &str) -> Value { + json!({ + "type": "boolean", + "description": description, + }) +} - let file_path = match read.path_mode { - PathMode::Absolute => tool_metadata::read::param::FILE_PATH_ABSOLUTE, - PathMode::Allowed => tool_metadata::read::param::FILE_PATH_ALLOWED, - }; - let description = match read.path_mode { - PathMode::Absolute => tool_metadata::read::description::absolute(read.line_numbers), - PathMode::Allowed => tool_metadata::read::description::allowed(read.line_numbers), - }; +fn enum_schema(description: &str, values: &[&str]) -> Value { + json!({ + "type": "string", + "description": description, + "enum": values, + }) +} + +fn integer_schema(description: &str, minimum: Option, maximum: Option) -> Value { + let mut schema = Map::with_capacity(4); + schema.insert("type".to_string(), Value::String("integer".to_string())); + schema.insert( + "description".to_string(), + Value::String(description.to_string()), + ); + if let Some(min) = minimum { + schema.insert("minimum".to_string(), Value::from(min)); + } + if let Some(max) = maximum { + schema.insert("maximum".to_string(), Value::from(max)); + } + Value::Object(schema) +} + +fn push_bash_definition(definitions: &mut Vec, case: PromptCase) { + if !case.bash { + return; + } definitions.push(tool_definition( - tool_metadata::read::NAME, - description, + tool_metadata::bash::NAME, + tool_metadata::bash::DESCRIPTION, object_schema( vec![ - (file_path.name, string_schema(file_path.description)), ( - tool_metadata::read::param::OFFSET.name, - integer_schema( - tool_metadata::read::param::OFFSET.description, + tool_metadata::bash::param::COMMAND.name, + string_schema_constrained( + tool_metadata::bash::param::COMMAND.description, Some(1), None, ), ), ( - tool_metadata::read::param::LIMIT.name, - integer_schema(tool_metadata::read::param::LIMIT.description, Some(1), None), + tool_metadata::bash::param::WORKDIR.name, + string_schema(tool_metadata::bash::param::WORKDIR.description), ), - ], - &[file_path.name], - ), - )); -} - -fn push_write_definition(definitions: &mut Vec, case: PromptCase) { - let Some(write) = case.write else { - return; - }; - - let file_path = match write { - PathMode::Absolute => tool_metadata::write::param::FILE_PATH_ABSOLUTE, - PathMode::Allowed => tool_metadata::write::param::FILE_PATH_ALLOWED, - }; - let description = match write { - PathMode::Absolute => tool_metadata::write::description::ABSOLUTE, - PathMode::Allowed => tool_metadata::write::description::ALLOWED, - }; - - definitions.push(tool_definition( - tool_metadata::write::NAME, - description, - object_schema( - vec![ - (file_path.name, string_schema(file_path.description)), ( - tool_metadata::write::param::CONTENT.name, - string_schema(tool_metadata::write::param::CONTENT.description), + tool_metadata::bash::param::TIMEOUT_MS.name, + integer_schema( + tool_metadata::bash::param::TIMEOUT_MS.description, + Some(1), + Some(tool_metadata::bash::MAX_TIMEOUT_MS as i64), + ), ), ], - &[file_path.name, tool_metadata::write::param::CONTENT.name], + &[tool_metadata::bash::param::COMMAND.name], ), )); } @@ -132,42 +130,6 @@ fn push_edit_definition(definitions: &mut Vec, case: PromptCase) { )); } -fn push_bash_definition(definitions: &mut Vec, case: PromptCase) { - if !case.bash { - return; - } - - definitions.push(tool_definition( - tool_metadata::bash::NAME, - tool_metadata::bash::DESCRIPTION, - object_schema( - vec![ - ( - tool_metadata::bash::param::COMMAND.name, - string_schema_constrained( - tool_metadata::bash::param::COMMAND.description, - Some(1), - None, - ), - ), - ( - tool_metadata::bash::param::WORKDIR.name, - string_schema(tool_metadata::bash::param::WORKDIR.description), - ), - ( - tool_metadata::bash::param::TIMEOUT_MS.name, - integer_schema( - tool_metadata::bash::param::TIMEOUT_MS.description, - Some(1), - Some(tool_metadata::bash::MAX_TIMEOUT_MS as i64), - ), - ), - ], - &[tool_metadata::bash::param::COMMAND.name], - ), - )); -} - fn push_glob_definition(definitions: &mut Vec, case: PromptCase) { let Some(glob) = case.glob else { return; @@ -240,34 +202,92 @@ fn push_grep_definition(definitions: &mut Vec, case: PromptCase) { )); } -fn push_webfetch_definition(definitions: &mut Vec, case: PromptCase) { - if !case.webfetch { +fn push_read_definition(definitions: &mut Vec, case: PromptCase) { + let Some(read) = case.read else { return; - } + }; + + let file_path = match read.path_mode { + PathMode::Absolute => tool_metadata::read::param::FILE_PATH_ABSOLUTE, + PathMode::Allowed => tool_metadata::read::param::FILE_PATH_ALLOWED, + }; + let description = match read.path_mode { + PathMode::Absolute => tool_metadata::read::description::absolute(read.line_numbers), + PathMode::Allowed => tool_metadata::read::description::allowed(read.line_numbers), + }; definitions.push(tool_definition( - tool_metadata::webfetch::NAME, - tool_metadata::webfetch::DESCRIPTION, + tool_metadata::read::NAME, + description, object_schema( vec![ + (file_path.name, string_schema(file_path.description)), ( - tool_metadata::webfetch::param::URL.name, - string_schema(tool_metadata::webfetch::param::URL.description), - ), - ( - tool_metadata::webfetch::param::TIMEOUT_MS.name, + tool_metadata::read::param::OFFSET.name, integer_schema( - tool_metadata::webfetch::param::TIMEOUT_MS.description, + tool_metadata::read::param::OFFSET.description, Some(1), - Some(tool_metadata::webfetch::MAX_TIMEOUT_MS as i64), + None, ), ), + ( + tool_metadata::read::param::LIMIT.name, + integer_schema(tool_metadata::read::param::LIMIT.description, Some(1), None), + ), ], - &[tool_metadata::webfetch::param::URL.name], + &[file_path.name], ), )); } +fn push_task_definition(definitions: &mut Vec, case: PromptCase) { + if case.task_targets.is_empty() { + return; + } + + definitions.push(tool_definition( + tool_metadata::task::NAME, + &task_description(case.task_targets), + object_schema( + vec![ + ( + tool_metadata::task::param::DESCRIPTION.name, + string_schema(tool_metadata::task::param::DESCRIPTION.description), + ), + ( + tool_metadata::task::param::PROMPT.name, + string_schema(tool_metadata::task::param::PROMPT.description), + ), + ( + tool_metadata::task::param::SUBAGENT_TYPE.name, + string_schema(tool_metadata::task::param::SUBAGENT_TYPE.description), + ), + ( + tool_metadata::task::param::COMMAND.name, + string_schema(tool_metadata::task::param::COMMAND.description), + ), + ], + &[ + tool_metadata::task::param::DESCRIPTION.name, + tool_metadata::task::param::PROMPT.name, + tool_metadata::task::param::SUBAGENT_TYPE.name, + ], + ), + )); +} + +fn push_todo_read_definition(definitions: &mut Vec, case: PromptCase) { + if !case.todo_read { + return; + } + + definitions.push(tool_definition( + tool_metadata::todo_read::NAME, + tool_metadata::todo_read::DESCRIPTION, + object_schema(Vec::new(), &[]), + )); +} + fn push_todo_write_definition(definitions: &mut Vec, case: PromptCase) { if !case.todo_write { return; @@ -304,98 +324,62 @@ fn push_todo_write_definition(definitions: &mut Vec, case: PromptCase) { )); } -fn push_todo_read_definition(definitions: &mut Vec, case: PromptCase) { - if !case.todo_read { - return; - } - - definitions.push(tool_definition( - tool_metadata::todo_read::NAME, - tool_metadata::todo_read::DESCRIPTION, - object_schema(Vec::new(), &[]), - )); -} - -fn push_task_definition(definitions: &mut Vec, case: PromptCase) { - if case.task_targets.is_empty() { +fn push_webfetch_definition(definitions: &mut Vec, case: PromptCase) { + if !case.webfetch { return; } definitions.push(tool_definition( - tool_metadata::task::NAME, - &task_description(case.task_targets), + tool_metadata::webfetch::NAME, + tool_metadata::webfetch::DESCRIPTION, object_schema( vec![ ( - tool_metadata::task::param::DESCRIPTION.name, - string_schema(tool_metadata::task::param::DESCRIPTION.description), - ), - ( - tool_metadata::task::param::PROMPT.name, - string_schema(tool_metadata::task::param::PROMPT.description), - ), - ( - tool_metadata::task::param::SUBAGENT_TYPE.name, - string_schema(tool_metadata::task::param::SUBAGENT_TYPE.description), + tool_metadata::webfetch::param::URL.name, + string_schema(tool_metadata::webfetch::param::URL.description), ), ( - tool_metadata::task::param::COMMAND.name, - string_schema(tool_metadata::task::param::COMMAND.description), + tool_metadata::webfetch::param::TIMEOUT_MS.name, + integer_schema( + tool_metadata::webfetch::param::TIMEOUT_MS.description, + Some(1), + Some(tool_metadata::webfetch::MAX_TIMEOUT_MS as i64), + ), ), ], - &[ - tool_metadata::task::param::DESCRIPTION.name, - tool_metadata::task::param::PROMPT.name, - tool_metadata::task::param::SUBAGENT_TYPE.name, - ], + &[tool_metadata::webfetch::param::URL.name], ), )); } -fn task_description(targets: &[TaskTarget]) -> String { - let mut description = String::with_capacity(128 + targets.len() * 48); - description.push_str(tool_metadata::task::DESCRIPTION_PREFIX); - description.push_str("\n\nAvailable subagents:\n"); - for target in targets { - description.push_str("- "); - description.push_str(target.name); - description.push_str(": "); - description.push_str(target.description); - description.push('\n'); - } - description.truncate(description.trim_end().len()); - description -} - -fn tool_definition(name: &str, description: &str, parameters: Value) -> Value { - json!({ - "name": name, - "description": description, - "parameters": parameters, - }) -} +fn push_write_definition(definitions: &mut Vec, case: PromptCase) { + let Some(write) = case.write else { + return; + }; -fn object_schema(properties: Vec<(&str, Value)>, required: &[&str]) -> Value { - let mut props = Map::with_capacity(properties.len()); - for (name, schema) in properties { - props.insert(name.to_string(), schema); - } + let file_path = match write { + PathMode::Absolute => tool_metadata::write::param::FILE_PATH_ABSOLUTE, + PathMode::Allowed => tool_metadata::write::param::FILE_PATH_ALLOWED, + }; + let description = match write { + PathMode::Absolute => tool_metadata::write::description::ABSOLUTE, + PathMode::Allowed => tool_metadata::write::description::ALLOWED, + }; - let mut object = Map::with_capacity(3); - object.insert("type".to_string(), Value::String("object".to_string())); - object.insert("properties".to_string(), Value::Object(props)); - if !required.is_empty() { - object.insert( - "required".to_string(), - Value::Array( - required - .iter() - .map(|name| Value::String((*name).to_string())) - .collect(), - ), - ); - } - Value::Object(object) + definitions.push(tool_definition( + tool_metadata::write::NAME, + description, + object_schema( + vec![ + (file_path.name, string_schema(file_path.description)), + ( + tool_metadata::write::param::CONTENT.name, + string_schema(tool_metadata::write::param::CONTENT.description), + ), + ], + &[file_path.name, tool_metadata::write::param::CONTENT.name], + ), + )); } fn string_schema(description: &str) -> Value { @@ -425,33 +409,48 @@ fn string_schema_constrained( Value::Object(schema) } -fn integer_schema(description: &str, minimum: Option, maximum: Option) -> Value { - let mut schema = Map::with_capacity(4); - schema.insert("type".to_string(), Value::String("integer".to_string())); - schema.insert( - "description".to_string(), - Value::String(description.to_string()), - ); - if let Some(min) = minimum { - schema.insert("minimum".to_string(), Value::from(min)); +fn object_schema(properties: Vec<(&str, Value)>, required: &[&str]) -> Value { + let mut props = Map::with_capacity(properties.len()); + for (name, schema) in properties { + props.insert(name.to_string(), schema); } - if let Some(max) = maximum { - schema.insert("maximum".to_string(), Value::from(max)); + + let mut object = Map::with_capacity(3); + object.insert("type".to_string(), Value::String("object".to_string())); + object.insert("properties".to_string(), Value::Object(props)); + if !required.is_empty() { + object.insert( + "required".to_string(), + Value::Array( + required + .iter() + .map(|name| Value::String((*name).to_string())) + .collect(), + ), + ); } - Value::Object(schema) + Value::Object(object) } -fn boolean_schema(description: &str) -> Value { - json!({ - "type": "boolean", - "description": description, - }) +fn task_description(targets: &[TaskTarget]) -> String { + let mut description = String::with_capacity(128 + targets.len() * 48); + description.push_str(tool_metadata::task::DESCRIPTION_PREFIX); + description.push_str("\n\nAvailable subagents:\n"); + for target in targets { + description.push_str("- "); + description.push_str(target.name); + description.push_str(": "); + description.push_str(target.description); + description.push('\n'); + } + description.truncate(description.trim_end().len()); + description } -fn enum_schema(description: &str, values: &[&str]) -> Value { +fn tool_definition(name: &str, description: &str, parameters: Value) -> Value { json!({ - "type": "string", + "name": name, "description": description, - "enum": values, + "parameters": parameters, }) } diff --git a/src/reloaded-code-core/examples/system_prompt/mock_tools.rs b/src/reloaded-code-core/examples/system_prompt/mock_tools.rs index 83ef1db8..b699cf33 100644 --- a/src/reloaded-code-core/examples/system_prompt/mock_tools.rs +++ b/src/reloaded-code-core/examples/system_prompt/mock_tools.rs @@ -1,9 +1,120 @@ //! Example-only mock tools used to build prompt previews. +use super::{GrepConfig, PromptCase, ReadConfig}; use reloaded_code_core::context::{PathMode, ToolContext, ToolPrompt}; use reloaded_code_core::{tool_metadata, SystemPromptBuilder}; -use super::{GrepConfig, PromptCase, ReadConfig}; +macro_rules! path_tool { + ($tool:ident, $name:path, $variant:ident) => { + struct $tool; + + impl ToolContext for $tool { + fn name(&self) -> &'static str { + $name + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::$variant { + path_mode: path_mode::(), + } + } + } + }; +} + +path_tool!(MockWriteTool, tool_metadata::write::NAME, Write); + +path_tool!(MockEditTool, tool_metadata::edit::NAME, Edit); + +path_tool!(MockGlobTool, tool_metadata::glob::NAME, Glob); + +macro_rules! path_tool_with_line_numbers { + ($tool:ident, $name:path, $variant:ident) => { + struct $tool; + + impl ToolContext + for $tool + { + fn name(&self) -> &'static str { + $name + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::$variant { + path_mode: path_mode::(), + line_numbers: LINE_NUMBERS, + } + } + } + }; +} + +path_tool_with_line_numbers!(MockReadTool, tool_metadata::read::NAME, Read); + +path_tool_with_line_numbers!(MockGrepTool, tool_metadata::grep::NAME, Grep); + +struct MockBashTool; + +struct MockTaskTool; + +struct MockTodoReadTool; + +struct MockTodoWriteTool; + +struct MockWebFetchTool; + +impl ToolContext for MockBashTool { + fn name(&self) -> &'static str { + tool_metadata::bash::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Bash { + network_disabled: false, + sandboxed: false, + } + } +} + +impl ToolContext for MockTaskTool { + fn name(&self) -> &'static str { + tool_metadata::task::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Task + } +} + +impl ToolContext for MockTodoReadTool { + fn name(&self) -> &'static str { + tool_metadata::todo_read::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::TodoRead + } +} + +impl ToolContext for MockTodoWriteTool { + fn name(&self) -> &'static str { + tool_metadata::todo_write::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::TodoWrite + } +} + +impl ToolContext for MockWebFetchTool { + fn name(&self) -> &'static str { + tool_metadata::webfetch::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::WebFetch + } +} /// Registers the mock tools needed for one prompt example case. pub(super) fn track_case_tools(builder: &mut SystemPromptBuilder, case: PromptCase) { @@ -39,34 +150,6 @@ pub(super) fn track_case_tools(builder: &mut SystemPromptBuilder, case: PromptCa } } -fn track_read(builder: &mut SystemPromptBuilder, config: ReadConfig) { - match (config.path_mode, config.line_numbers) { - (PathMode::Absolute, true) => { - let _ = builder.track(MockReadTool::); - } - (PathMode::Absolute, false) => { - let _ = builder.track(MockReadTool::); - } - (PathMode::Allowed, true) => { - let _ = builder.track(MockReadTool::); - } - (PathMode::Allowed, false) => { - let _ = builder.track(MockReadTool::); - } - } -} - -fn track_write(builder: &mut SystemPromptBuilder, path_mode: PathMode) { - match path_mode { - PathMode::Absolute => { - let _ = builder.track(MockWriteTool::); - } - PathMode::Allowed => { - let _ = builder.track(MockWriteTool::); - } - } -} - fn track_edit(builder: &mut SystemPromptBuilder, path_mode: PathMode) { match path_mode { PathMode::Absolute => { @@ -106,118 +189,38 @@ fn track_grep(builder: &mut SystemPromptBuilder, config: GrepConfig) { } } -const fn path_mode() -> PathMode { - if ALLOWED { - PathMode::Allowed - } else { - PathMode::Absolute - } -} - -macro_rules! path_tool_with_line_numbers { - ($tool:ident, $name:path, $variant:ident) => { - struct $tool; - - impl ToolContext - for $tool - { - fn name(&self) -> &'static str { - $name - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::$variant { - path_mode: path_mode::(), - line_numbers: LINE_NUMBERS, - } - } +fn track_read(builder: &mut SystemPromptBuilder, config: ReadConfig) { + match (config.path_mode, config.line_numbers) { + (PathMode::Absolute, true) => { + let _ = builder.track(MockReadTool::); } - }; -} - -macro_rules! path_tool { - ($tool:ident, $name:path, $variant:ident) => { - struct $tool; - - impl ToolContext for $tool { - fn name(&self) -> &'static str { - $name - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::$variant { - path_mode: path_mode::(), - } - } + (PathMode::Absolute, false) => { + let _ = builder.track(MockReadTool::); } - }; -} - -path_tool_with_line_numbers!(MockReadTool, tool_metadata::read::NAME, Read); -path_tool!(MockWriteTool, tool_metadata::write::NAME, Write); -path_tool!(MockEditTool, tool_metadata::edit::NAME, Edit); -path_tool!(MockGlobTool, tool_metadata::glob::NAME, Glob); -path_tool_with_line_numbers!(MockGrepTool, tool_metadata::grep::NAME, Grep); - -struct MockBashTool; - -impl ToolContext for MockBashTool { - fn name(&self) -> &'static str { - tool_metadata::bash::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::Bash { - network_disabled: false, - sandboxed: false, + (PathMode::Allowed, true) => { + let _ = builder.track(MockReadTool::); + } + (PathMode::Allowed, false) => { + let _ = builder.track(MockReadTool::); } } } -struct MockWebFetchTool; - -impl ToolContext for MockWebFetchTool { - fn name(&self) -> &'static str { - tool_metadata::webfetch::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::WebFetch - } -} - -struct MockTodoWriteTool; - -impl ToolContext for MockTodoWriteTool { - fn name(&self) -> &'static str { - tool_metadata::todo_write::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::TodoWrite - } -} - -struct MockTodoReadTool; - -impl ToolContext for MockTodoReadTool { - fn name(&self) -> &'static str { - tool_metadata::todo_read::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::TodoRead +fn track_write(builder: &mut SystemPromptBuilder, path_mode: PathMode) { + match path_mode { + PathMode::Absolute => { + let _ = builder.track(MockWriteTool::); + } + PathMode::Allowed => { + let _ = builder.track(MockWriteTool::); + } } } -struct MockTaskTool; - -impl ToolContext for MockTaskTool { - fn name(&self) -> &'static str { - tool_metadata::task::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::Task +const fn path_mode() -> PathMode { + if ALLOWED { + PathMode::Allowed + } else { + PathMode::Absolute } } diff --git a/src/reloaded-code-core/examples/system_prompt/mod.rs b/src/reloaded-code-core/examples/system_prompt/mod.rs index 8a297b76..9c30a3ac 100644 --- a/src/reloaded-code-core/examples/system_prompt/mod.rs +++ b/src/reloaded-code-core/examples/system_prompt/mod.rs @@ -16,18 +16,18 @@ //! - [`section_sizes`] ranks rendered guideline sections by size. //! - [`PromptCase`] and related config types describe one example scenario. -mod build; -mod definitions; -mod mock_tools; -mod report; -mod types; - pub use build::build_case; pub use report::{ estimate_tokens, print_footprint, print_ranked_sizes, print_tool_definitions, section_sizes, }; pub use types::{GrepConfig, PromptArtifacts, PromptCase, ReadConfig, TaskTarget}; +mod build; +mod definitions; +mod mock_tools; +mod report; +mod types; + fn sort_sizes_desc(sizes: &mut [(String, usize)]) { sizes.sort_unstable_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); } diff --git a/src/reloaded-code-core/examples/system_prompt/report.rs b/src/reloaded-code-core/examples/system_prompt/report.rs index ea90af13..7167801b 100644 --- a/src/reloaded-code-core/examples/system_prompt/report.rs +++ b/src/reloaded-code-core/examples/system_prompt/report.rs @@ -38,13 +38,6 @@ pub fn print_ranked_sizes(title: &str, sizes: &[(String, usize)]) { } } -/// Returns rendered tool-guideline section sizes sorted from largest to smallest. -pub fn section_sizes(artifacts: &PromptArtifacts) -> Vec<(String, usize)> { - let mut sections = artifacts.guideline_sections.clone(); - sort_sizes_desc(&mut sections); - sections -} - pub fn print_tool_definitions(artifacts: &super::PromptArtifacts) { println!("\n{}", "=".repeat(60)); println!("Tool Definitions:"); @@ -55,6 +48,13 @@ pub fn print_tool_definitions(artifacts: &super::PromptArtifacts) { } } +/// Returns rendered tool-guideline section sizes sorted from largest to smallest. +pub fn section_sizes(artifacts: &PromptArtifacts) -> Vec<(String, usize)> { + let mut sections = artifacts.guideline_sections.clone(); + sort_sizes_desc(&mut sections); + sections +} + pub(super) fn collect_guideline_sections(prompt: &str) -> Vec<(String, usize)> { let mut in_guidelines = false; let mut current_name: Option = None; diff --git a/src/reloaded-code-core/examples/system_prompt/types.rs b/src/reloaded-code-core/examples/system_prompt/types.rs index ea0d840e..03777f00 100644 --- a/src/reloaded-code-core/examples/system_prompt/types.rs +++ b/src/reloaded-code-core/examples/system_prompt/types.rs @@ -1,27 +1,13 @@ +use super::sort_sizes_desc; use reloaded_code_core::context::PathMode; use serde_json::Value; -use super::sort_sizes_desc; - -/// Configures the `read` tool for one example case. -#[derive(Debug, Clone, Copy)] -pub struct ReadConfig { - pub path_mode: PathMode, - pub line_numbers: bool, -} - -/// Configures the `grep` tool for one example case. -#[derive(Debug, Clone, Copy)] -pub struct GrepConfig { - pub path_mode: PathMode, - pub line_numbers: bool, -} - -/// Describes one subagent target for the `task` example definition. -#[derive(Debug, Clone, Copy)] -pub struct TaskTarget { - pub name: &'static str, - pub description: &'static str, +/// Holds the rendered prompt and serialized tool definitions for one case. +pub struct PromptArtifacts { + pub system_prompt: String, + pub tool_definitions: Vec, + pub tool_definition_payload: String, + pub guideline_sections: Vec<(String, usize)>, } /// Describes one system prompt example scenario. @@ -44,21 +30,25 @@ pub struct PromptCase { pub task_targets: &'static [TaskTarget], } -impl PromptCase { - /// Returns the same case without supplemental git workflow sections. - pub fn without_supplemental(mut self) -> Self { - self.include_git_workflow = false; - self.include_github_cli = false; - self - } +/// Configures the `grep` tool for one example case. +#[derive(Debug, Clone, Copy)] +pub struct GrepConfig { + pub path_mode: PathMode, + pub line_numbers: bool, } -/// Holds the rendered prompt and serialized tool definitions for one case. -pub struct PromptArtifacts { - pub system_prompt: String, - pub tool_definitions: Vec, - pub tool_definition_payload: String, - pub guideline_sections: Vec<(String, usize)>, +/// Configures the `read` tool for one example case. +#[derive(Debug, Clone, Copy)] +pub struct ReadConfig { + pub path_mode: PathMode, + pub line_numbers: bool, +} + +/// Describes one subagent target for the `task` example definition. +#[derive(Debug, Clone, Copy)] +pub struct TaskTarget { + pub name: &'static str, + pub description: &'static str, } impl PromptArtifacts { @@ -82,3 +72,12 @@ impl PromptArtifacts { sizes } } + +impl PromptCase { + /// Returns the same case without supplemental git workflow sections. + pub fn without_supplemental(mut self) -> Self { + self.include_git_workflow = false; + self.include_github_cli = false; + self + } +} diff --git a/src/reloaded-code-core/examples/system_prompt_preview.rs b/src/reloaded-code-core/examples/system_prompt_preview.rs index 363b859b..c1adbc5c 100644 --- a/src/reloaded-code-core/examples/system_prompt_preview.rs +++ b/src/reloaded-code-core/examples/system_prompt_preview.rs @@ -2,14 +2,26 @@ //! //! Run: cargo run --example system_prompt_preview -p reloaded-code-core -mod system_prompt; - use reloaded_code_core::context::PathMode; use system_prompt::{ build_case, print_footprint, print_ranked_sizes, print_tool_definitions, section_sizes, GrepConfig, PromptCase, ReadConfig, TaskTarget, }; +mod system_prompt; + +const SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Follow best practices and write clean, maintainable code."; +const TASK_TARGETS: &[TaskTarget] = &[ + TaskTarget { + name: "research", + description: "Investigate implementation details and report back.", + }, + TaskTarget { + name: "review", + description: "Review code and suggest focused fixes.", + }, +]; + fn main() { let full = build_case(full_case()); let without_supplemental = build_case(full_case().without_supplemental()); @@ -29,19 +41,6 @@ fn main() { print_footprint(" Static request footprint", &without_supplemental); } -const SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Follow best practices and write clean, maintainable code."; - -const TASK_TARGETS: &[TaskTarget] = &[ - TaskTarget { - name: "research", - description: "Investigate implementation details and report back.", - }, - TaskTarget { - name: "review", - description: "Review code and suggest focused fixes.", - }, -]; - fn full_case() -> PromptCase { PromptCase { system_prompt: SYSTEM_PROMPT, diff --git a/src/reloaded-code-core/examples/system_prompt_preview_compare.rs b/src/reloaded-code-core/examples/system_prompt_preview_compare.rs index 7db7bae3..8d2a79bb 100644 --- a/src/reloaded-code-core/examples/system_prompt_preview_compare.rs +++ b/src/reloaded-code-core/examples/system_prompt_preview_compare.rs @@ -2,14 +2,27 @@ //! //! Run: cargo run --example system_prompt_preview_compare -p reloaded-code-core -mod system_prompt; - use reloaded_code_core::context::PathMode; use system_prompt::{ build_case, estimate_tokens, print_footprint, GrepConfig, PromptArtifacts, PromptCase, ReadConfig, TaskTarget, }; +mod system_prompt; + +const FULL_SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Follow best practices and write clean, maintainable code."; +const READONLY_SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Gather relevant information and report concise findings."; +const TASK_TARGETS: &[TaskTarget] = &[ + TaskTarget { + name: "research", + description: "Investigate implementation details and report back.", + }, + TaskTarget { + name: "review", + description: "Review code and suggest focused fixes.", + }, +]; + fn main() { let full = build_case(full_case()); let no_supplemental = build_case(full_case().without_supplemental()); @@ -26,21 +39,6 @@ fn main() { print_delta(" Readonly", &full, &readonly); } -const FULL_SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Follow best practices and write clean, maintainable code."; - -const READONLY_SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Gather relevant information and report concise findings."; - -const TASK_TARGETS: &[TaskTarget] = &[ - TaskTarget { - name: "research", - description: "Investigate implementation details and report back.", - }, - TaskTarget { - name: "review", - description: "Review code and suggest focused fixes.", - }, -]; - fn full_case() -> PromptCase { PromptCase { system_prompt: FULL_SYSTEM_PROMPT, @@ -67,6 +65,26 @@ fn full_case() -> PromptCase { } } +fn print_delta(label: &str, full: &PromptArtifacts, other: &PromptArtifacts) { + let prompt_saved = full + .system_prompt + .len() + .saturating_sub(other.system_prompt.len()); + let definitions_saved = full + .tool_definition_payload + .len() + .saturating_sub(other.tool_definition_payload.len()); + let total_saved = full.total_chars().saturating_sub(other.total_chars()); + + println!( + "{label}: -{} prompt chars, -{} definition chars, -{} total chars (~{} tokens)", + prompt_saved, + definitions_saved, + total_saved, + estimate_tokens(total_saved) + ); +} + fn readonly_case() -> PromptCase { PromptCase { system_prompt: READONLY_SYSTEM_PROMPT, @@ -92,23 +110,3 @@ fn readonly_case() -> PromptCase { task_targets: &[], } } - -fn print_delta(label: &str, full: &PromptArtifacts, other: &PromptArtifacts) { - let prompt_saved = full - .system_prompt - .len() - .saturating_sub(other.system_prompt.len()); - let definitions_saved = full - .tool_definition_payload - .len() - .saturating_sub(other.tool_definition_payload.len()); - let total_saved = full.total_chars().saturating_sub(other.total_chars()); - - println!( - "{label}: -{} prompt chars, -{} definition chars, -{} total chars (~{} tokens)", - prompt_saved, - definitions_saved, - total_saved, - estimate_tokens(total_saved) - ); -} diff --git a/src/reloaded-code-core/examples/system_prompt_preview_readonly.rs b/src/reloaded-code-core/examples/system_prompt_preview_readonly.rs index d6e37f41..4d8ff033 100644 --- a/src/reloaded-code-core/examples/system_prompt_preview_readonly.rs +++ b/src/reloaded-code-core/examples/system_prompt_preview_readonly.rs @@ -2,14 +2,16 @@ //! //! Run: cargo run --example system_prompt_preview_readonly -p reloaded-code-core -mod system_prompt; - use reloaded_code_core::context::PathMode; use system_prompt::{ build_case, print_footprint, print_ranked_sizes, print_tool_definitions, section_sizes, GrepConfig, PromptCase, ReadConfig, }; +mod system_prompt; + +const SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Gather relevant information and report concise findings."; + fn main() { let readonly = build_case(readonly_case()); @@ -22,8 +24,6 @@ fn main() { print_tool_definitions(&readonly); } -const SYSTEM_PROMPT: &str = "# System Instructions\n\nYou are a helpful coding assistant. Gather relevant information and report concise findings."; - fn readonly_case() -> PromptCase { PromptCase { system_prompt: SYSTEM_PROMPT, diff --git a/src/reloaded-code-core/src/context/mod.rs b/src/reloaded-code-core/src/context/mod.rs index b0ab2561..2eda1c6b 100644 --- a/src/reloaded-code-core/src/context/mod.rs +++ b/src/reloaded-code-core/src/context/mod.rs @@ -37,22 +37,23 @@ //! } //! ``` -mod tool_prompt; - pub use tool_prompt::{PathMode, ToolPrompt}; pub(crate) use tool_prompt::{ToolPromptFacts, COMMON_RULES_HEADER, COMMON_RULES_SECTION_MAX_SIZE}; -/// Git workflow context - commit creation guidance. -/// -/// Supplemental context for agents using git via the `bash` tool. -/// Include via [`SystemPromptBuilder::add_context`](crate::SystemPromptBuilder::add_context). -pub const GIT_WORKFLOW: &str = include_str!("git_workflow.txt"); +mod tool_prompt; /// GitHub CLI context - gh command usage guidance. /// /// Supplemental context for agents using the GitHub CLI via the `bash` tool. -/// Include via [`SystemPromptBuilder::add_context`](crate::SystemPromptBuilder::add_context). +/// Include via [`SystemPromptBuilder::add_context`]. +/// +/// [`SystemPromptBuilder::add_context`]: crate::SystemPromptBuilder::add_context pub const GITHUB_CLI: &str = include_str!("github_cli.txt"); +/// Git workflow context - commit creation guidance. +/// +/// Supplemental context for agents using git via the `bash` tool. +/// Include via [`SystemPromptBuilder::add_context`]. +pub const GIT_WORKFLOW: &str = include_str!("git_workflow.txt"); /// Trait for tools that provide guidance for system prompts. /// diff --git a/src/reloaded-code-core/src/context/tool_prompt/common_rules.rs b/src/reloaded-code-core/src/context/tool_prompt/common_rules.rs index 28d6ec92..9182bcd5 100644 --- a/src/reloaded-code-core/src/context/tool_prompt/common_rules.rs +++ b/src/reloaded-code-core/src/context/tool_prompt/common_rules.rs @@ -3,10 +3,9 @@ //! These helpers add rules that apply to more than one tool. Each rule is only //! included when the matching tools are present. -use const_format::formatcp; - use super::{push_line, write_tool_list, ToolPromptFacts}; use crate::tool_metadata::{bash, edit, glob, grep, read, write}; +use const_format::formatcp; /// Writes the shared rules for the current built-in tools. pub(super) fn write_common_rules(facts: ToolPromptFacts, output: &mut String) { @@ -67,48 +66,6 @@ fn append_bash_rule(facts: ToolPromptFacts, output: &mut String) { ); } -/// Adds the rule that separates file search, content search, and full reads. -fn append_search_rule(facts: ToolPromptFacts, output: &mut String) { - match (facts.has_glob, facts.has_grep, facts.has_read) { - (true, true, true) => push_line( - output, - formatcp!( - "- Use `{}` for file-name search, `{}` for content search, and `{}` for file content.", - glob::NAME, - grep::NAME, - read::NAME, - ), - ), - (true, true, false) => push_line( - output, - formatcp!("- Use `{}` for file-name search and `{}` for content search.", glob::NAME, grep::NAME), - ), - (true, false, true) => push_line( - output, - formatcp!("- Use `{}` to find files and `{}` for file content.", glob::NAME, read::NAME), - ), - (false, true, true) => push_line( - output, - formatcp!("- Use `{}` for content search and `{}` for file content.", grep::NAME, read::NAME), - ), - _ => {} - } -} - -/// Adds the rule that points small changes to `edit` and rewrites to `write`. -fn append_write_rule(facts: ToolPromptFacts, output: &mut String) { - if facts.has_edit && facts.has_write { - push_line( - output, - formatcp!( - "- Prefer `{}` for targeted changes and `{}` for new files or full rewrites.", - edit::NAME, - write::NAME - ), - ); - } -} - /// Adds the rule to read a file before editing or overwriting it. fn append_read_before_edit_rule(facts: ToolPromptFacts, output: &mut String) { match (facts.has_read, facts.has_edit, facts.has_write, facts.read_line_numbers) { @@ -157,6 +114,48 @@ fn append_read_before_edit_rule(facts: ToolPromptFacts, output: &mut String) { } } +/// Adds the rule that separates file search, content search, and full reads. +fn append_search_rule(facts: ToolPromptFacts, output: &mut String) { + match (facts.has_glob, facts.has_grep, facts.has_read) { + (true, true, true) => push_line( + output, + formatcp!( + "- Use `{}` for file-name search, `{}` for content search, and `{}` for file content.", + glob::NAME, + grep::NAME, + read::NAME, + ), + ), + (true, true, false) => push_line( + output, + formatcp!("- Use `{}` for file-name search and `{}` for content search.", glob::NAME, grep::NAME), + ), + (true, false, true) => push_line( + output, + formatcp!("- Use `{}` to find files and `{}` for file content.", glob::NAME, read::NAME), + ), + (false, true, true) => push_line( + output, + formatcp!("- Use `{}` for content search and `{}` for file content.", grep::NAME, read::NAME), + ), + _ => {} + } +} + +/// Adds the rule that points small changes to `edit` and rewrites to `write`. +fn append_write_rule(facts: ToolPromptFacts, output: &mut String) { + if facts.has_edit && facts.has_write { + push_line( + output, + formatcp!( + "- Prefer `{}` for targeted changes and `{}` for new files or full rewrites.", + edit::NAME, + write::NAME + ), + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/context/tool_prompt/mod.rs b/src/reloaded-code-core/src/context/tool_prompt/mod.rs index afda2834..c7a07a19 100644 --- a/src/reloaded-code-core/src/context/tool_prompt/mod.rs +++ b/src/reloaded-code-core/src/context/tool_prompt/mod.rs @@ -16,19 +16,9 @@ mod tool_sections; /// Heading used for the shared rule block. pub(crate) const COMMON_RULES_HEADER: &str = "## Common Rules\n"; - /// Largest common-rules section length, including [`COMMON_RULES_HEADER`]. pub(crate) const COMMON_RULES_SECTION_MAX_SIZE: usize = 475; -/// Describes how a tool accepts paths. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PathMode { - /// The tool accepts absolute filesystem paths. - Absolute, - /// The tool accepts paths within allowed directories. - Allowed, -} - /// Describes the guidance to render for one tool. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolPrompt { @@ -77,13 +67,6 @@ pub enum ToolPrompt { Task, } -impl ToolPrompt { - /// Writes this tool's guidance into `output`. - pub(crate) fn render(self, output: &mut String, facts: ToolPromptFacts) { - tool_sections::render_tool(self, output, facts); - } -} - /// Tracks which built-in tools are present. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) struct ToolPromptFacts { @@ -97,6 +80,22 @@ pub(crate) struct ToolPromptFacts { has_grep: bool, } +/// Describes how a tool accepts paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathMode { + /// The tool accepts absolute filesystem paths. + Absolute, + /// The tool accepts paths within allowed directories. + Allowed, +} + +impl ToolPrompt { + /// Writes this tool's guidance into `output`. + pub(crate) fn render(self, output: &mut String, facts: ToolPromptFacts) { + tool_sections::render_tool(self, output, facts); + } +} + impl ToolPromptFacts { /// Builds the tool facts from the tracked prompts. pub(crate) fn from_prompts(prompts: impl IntoIterator) -> Self { @@ -166,15 +165,15 @@ impl ToolPromptFacts { } } +pub(super) fn push_block(output: &mut String, block: &str) { + output.push_str(block); +} + pub(super) fn push_line(output: &mut String, line: &str) { output.push_str(line); output.push('\n'); } -pub(super) fn push_block(output: &mut String, block: &str) { - output.push_str(block); -} - pub(super) fn write_tool_list(output: &mut String, tools: &[&str]) { match tools { [] => {} diff --git a/src/reloaded-code-core/src/context/tool_prompt/tool_sections.rs b/src/reloaded-code-core/src/context/tool_prompt/tool_sections.rs index 7aa570f7..4aeadb5c 100644 --- a/src/reloaded-code-core/src/context/tool_prompt/tool_sections.rs +++ b/src/reloaded-code-core/src/context/tool_prompt/tool_sections.rs @@ -3,10 +3,9 @@ //! Each helper in this module writes one tool's guidance text, which keeps the //! top-level renderer small and easy to follow. -use const_format::formatcp; - use super::{push_block, push_line, write_tool_list, ToolPrompt, ToolPromptFacts}; use crate::tool_metadata::{bash, edit, glob, grep, read, webfetch}; +use const_format::formatcp; /// Appends the guidance text for `prompt` into `output`. /// @@ -60,67 +59,6 @@ fn write_bash_section(output: &mut String, network_disabled: bool, sandboxed: bo } } -fn write_read_section(output: &mut String, facts: ToolPromptFacts, line_numbers: bool) { - if line_numbers { - push_line( - output, - formatcp!( - "- Returns `{}` text. Lines over `{}` chars are truncated.", - read::LINE_PREFIX_DISPLAY, - read::MAX_LINE_LENGTH, - ), - ); - } else { - push_line( - output, - formatcp!( - "- Returns raw text. Lines over `{}` chars are truncated.", - read::MAX_LINE_LENGTH, - ), - ); - } - - match (facts.has_glob, facts.has_bash) { - (true, true) => push_line( - output, - formatcp!( - "- Reads files, not directories. Use `{}` to find files or `{}` for directory listings.", - glob::NAME, - bash::NAME, - ), - ), - (true, false) => { - push_line( - output, - formatcp!("- Reads files, not directories. Use `{}` to find files.", glob::NAME), - ) - } - (false, true) => { - push_line( - output, - formatcp!("- Reads files, not directories. Use `{}` for directory listings.", bash::NAME), - ) - } - (false, false) => push_line(output, "- Reads files, not directories."), - } - - push_block( - output, - "- Missing files return an error. Non-text files are returned as text bytes; there is no special image rendering.\n\ -- Read related files in parallel when useful.\n", - ); -} - -fn write_write_section(output: &mut String, facts: ToolPromptFacts) { - push_line(output, "- Existing files are overwritten."); - if !facts.has_edit { - push_line( - output, - "- Use this for new files or full rewrites, not small edits.", - ); - } -} - fn write_edit_section(output: &mut String, facts: ToolPromptFacts) { if !facts.has_read { push_line( @@ -180,37 +118,54 @@ fn write_grep_section(output: &mut String, facts: ToolPromptFacts) { } } -fn write_webfetch_section(output: &mut String) { - push_block( - output, - formatcp!( - "- Output starts with `[content-type - bytes]`.\n\ - - Maximum response size is `{}` bytes.\n\ - - Use this for known URLs, not web search. Prefer a more specialized web tool when one exists.\n", - webfetch::MAX_RESPONSE_SIZE, - ), - ); -} +fn write_read_section(output: &mut String, facts: ToolPromptFacts, line_numbers: bool) { + if line_numbers { + push_line( + output, + formatcp!( + "- Returns `{}` text. Lines over `{}` chars are truncated.", + read::LINE_PREFIX_DISPLAY, + read::MAX_LINE_LENGTH, + ), + ); + } else { + push_line( + output, + formatcp!( + "- Returns raw text. Lines over `{}` chars are truncated.", + read::MAX_LINE_LENGTH, + ), + ); + } -fn write_todo_read_section(output: &mut String) { - push_block( - output, - "- Output is plain text: either `No tasks.` or one line per task with status icon, priority, id, and content.\n\ -- Use it before starting or resuming complex work when you need the current task list.\n", - ); -} + match (facts.has_glob, facts.has_bash) { + (true, true) => push_line( + output, + formatcp!( + "- Reads files, not directories. Use `{}` to find files or `{}` for directory listings.", + glob::NAME, + bash::NAME, + ), + ), + (true, false) => { + push_line( + output, + formatcp!("- Reads files, not directories. Use `{}` to find files.", glob::NAME), + ) + } + (false, true) => { + push_line( + output, + formatcp!("- Reads files, not directories. Use `{}` for directory listings.", bash::NAME), + ) + } + (false, false) => push_line(output, "- Reads files, not directories."), + } -fn write_todo_write_section(output: &mut String) { push_block( output, - formatcp!( - "- Use it for multi-step or non-trivial work, or when the user asks for task tracking. Skip it for a single small task.\n\ - - Send the full desired list each time; this tool replaces the whole list.\n\ - - `{}` and `{}` must not be empty.\n\ - - Keep task text short and imperative. Update statuses as you work; keep one `in_progress` task when practical.\n", - crate::tool_metadata::todo_write::param::ID.name, - crate::tool_metadata::todo_write::param::CONTENT.name, - ), + "- Missing files return an error. Non-text files are returned as text bytes; there is no special image rendering.\n\ +- Read related files in parallel when useful.\n", ); } @@ -245,3 +200,47 @@ fn write_task_section(output: &mut String, facts: ToolPromptFacts) { "- The delegated result is returned only to you, so summarize it for the user.", ); } + +fn write_todo_read_section(output: &mut String) { + push_block( + output, + "- Output is plain text: either `No tasks.` or one line per task with status icon, priority, id, and content.\n\ +- Use it before starting or resuming complex work when you need the current task list.\n", + ); +} + +fn write_todo_write_section(output: &mut String) { + push_block( + output, + formatcp!( + "- Use it for multi-step or non-trivial work, or when the user asks for task tracking. Skip it for a single small task.\n\ + - Send the full desired list each time; this tool replaces the whole list.\n\ + - `{}` and `{}` must not be empty.\n\ + - Keep task text short and imperative. Update statuses as you work; keep one `in_progress` task when practical.\n", + crate::tool_metadata::todo_write::param::ID.name, + crate::tool_metadata::todo_write::param::CONTENT.name, + ), + ); +} + +fn write_webfetch_section(output: &mut String) { + push_block( + output, + formatcp!( + "- Output starts with `[content-type - bytes]`.\n\ + - Maximum response size is `{}` bytes.\n\ + - Use this for known URLs, not web search. Prefer a more specialized web tool when one exists.\n", + webfetch::MAX_RESPONSE_SIZE, + ), + ); +} + +fn write_write_section(output: &mut String, facts: ToolPromptFacts) { + push_line(output, "- Existing files are overwritten."); + if !facts.has_edit { + push_line( + output, + "- Use this for new files or full rewrites, not small edits.", + ); + } +} diff --git a/src/reloaded-code-core/src/credentials.rs b/src/reloaded-code-core/src/credentials.rs index 4c7b263d..9dd72804 100644 --- a/src/reloaded-code-core/src/credentials.rs +++ b/src/reloaded-code-core/src/credentials.rs @@ -16,6 +16,12 @@ use ahash::AHashMap; +/// Resolves named credentials from explicit overrides map. +#[derive(Debug, Clone)] +pub struct CredentialResolver { + overrides: AHashMap, Box>, +} + /// Trait for resolving named credentials. /// /// Implemented by [`CredentialResolver`] regardless of its `READ_ENV` parameter. @@ -27,19 +33,6 @@ pub trait CredentialLookup { fn resolve(&self, name: &str) -> Option; } -/// Resolves named credentials from explicit overrides map. -#[derive(Debug, Clone)] -pub struct CredentialResolver { - overrides: AHashMap, Box>, -} - -impl Default for CredentialResolver { - #[inline] - fn default() -> Self { - Self::new() - } -} - impl CredentialResolver { /// Creates a resolver that checks overrides first and then falls back to environment variables. #[inline] @@ -68,6 +61,13 @@ impl CredentialResolver { } } +impl Default for CredentialResolver { + #[inline] + fn default() -> Self { + Self::new() + } +} + impl CredentialLookup for CredentialResolver { #[inline] fn resolve(&self, name: &str) -> Option { diff --git a/src/reloaded-code-core/src/custom_tool/mod.rs b/src/reloaded-code-core/src/custom_tool/mod.rs index cbcd31d9..13b5ee71 100644 --- a/src/reloaded-code-core/src/custom_tool/mod.rs +++ b/src/reloaded-code-core/src/custom_tool/mod.rs @@ -77,12 +77,6 @@ //! assert!(registry.get("my_tool").is_some()); //! ``` -pub(crate) mod definition; -pub(crate) mod factory; -pub(crate) mod registry; -pub(crate) mod runtime; -pub(crate) mod tool; - pub use crate::tool_context::ToolBuildContext; pub use definition::CustomToolDefinition; pub use factory::ToolFactory; @@ -90,9 +84,13 @@ pub use registry::{CustomToolRegistry, SharedToolRegistry}; pub use runtime::ToolRunContext; pub use tool::{CustomTool, CustomToolFuture}; +pub(crate) mod definition; +pub(crate) mod factory; +pub(crate) mod registry; +pub(crate) mod runtime; #[cfg(test)] pub(crate) mod test_stubs; - +pub(crate) mod tool; #[cfg(test)] mod tests { use super::test_stubs::{EchoFactory, TestFactory}; diff --git a/src/reloaded-code-core/src/custom_tool/registry.rs b/src/reloaded-code-core/src/custom_tool/registry.rs index e26eacd1..a62a6522 100644 --- a/src/reloaded-code-core/src/custom_tool/registry.rs +++ b/src/reloaded-code-core/src/custom_tool/registry.rs @@ -5,17 +5,38 @@ use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; +/// Shared wrapper around a [`CustomToolRegistry`], cheaply cloneable via [`Arc`]. +/// +/// Cloning shares the same underlying map, making it cheap to pass through +/// runtime builders and framework adapters. +#[derive(Debug, Clone)] +pub struct SharedToolRegistry { + inner: Arc, +} + /// Registry of custom tool factories, keyed by tool name. #[derive(Default)] pub struct CustomToolRegistry { factories: HashMap<&'static str, Box>, } -impl std::fmt::Debug for CustomToolRegistry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CustomToolRegistry") - .field("factories", &self.factories.keys().collect::>()) - .finish() +impl SharedToolRegistry { + /// Creates an empty registry. + #[inline] + #[must_use] + pub fn new() -> Self { + Self { + inner: Arc::new(CustomToolRegistry::new()), + } + } + + /// Creates a shared registry from a populated [`CustomToolRegistry`]. + #[inline] + #[must_use] + pub fn from_registry(registry: CustomToolRegistry) -> Self { + Self { + inner: Arc::new(registry), + } } } @@ -57,35 +78,6 @@ impl CustomToolRegistry { } } -/// Shared wrapper around a [`CustomToolRegistry`], cheaply cloneable via [`Arc`]. -/// -/// Cloning shares the same underlying map, making it cheap to pass through -/// runtime builders and framework adapters. -#[derive(Debug, Clone)] -pub struct SharedToolRegistry { - inner: Arc, -} - -impl SharedToolRegistry { - /// Creates an empty registry. - #[inline] - #[must_use] - pub fn new() -> Self { - Self { - inner: Arc::new(CustomToolRegistry::new()), - } - } - - /// Creates a shared registry from a populated [`CustomToolRegistry`]. - #[inline] - #[must_use] - pub fn from_registry(registry: CustomToolRegistry) -> Self { - Self { - inner: Arc::new(registry), - } - } -} - impl Deref for SharedToolRegistry { type Target = CustomToolRegistry; @@ -101,3 +93,11 @@ impl Default for SharedToolRegistry { Self::new() } } + +impl std::fmt::Debug for CustomToolRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CustomToolRegistry") + .field("factories", &self.factories.keys().collect::>()) + .finish() + } +} diff --git a/src/reloaded-code-core/src/custom_tool/test_stubs.rs b/src/reloaded-code-core/src/custom_tool/test_stubs.rs index 5d83eea4..aab96219 100644 --- a/src/reloaded-code-core/src/custom_tool/test_stubs.rs +++ b/src/reloaded-code-core/src/custom_tool/test_stubs.rs @@ -5,12 +5,31 @@ use crate::context::{ToolContext, ToolPrompt}; use crate::{ToolOutput, ToolResult}; use std::sync::Arc; +/// Factory that returns a portable echo tool for registry tests. +pub(crate) struct EchoFactory { + /// Tool name passed to [`ToolContext::name`]. + pub(crate) tool_name: &'static str, +} + /// Minimal factory returning a configurable prompt and empty boxed value. pub(crate) struct TestFactory { pub(crate) tool_name: &'static str, pub(crate) prompt: &'static str, } +/// Minimal portable custom tool used by factories above. +struct TestTool { + tool_name: &'static str, + prompt: &'static str, +} + +impl EchoFactory { + /// Creates a new [`EchoFactory`] with the given tool name. + pub(crate) fn new(name: &'static str) -> Self { + Self { tool_name: name } + } +} + impl TestFactory { pub(crate) fn new(name: &'static str, prompt: &'static str) -> Self { Self { @@ -20,63 +39,44 @@ impl TestFactory { } } -impl ToolContext for TestFactory { +impl ToolContext for EchoFactory { fn name(&self) -> &'static str { self.tool_name } fn context(&self) -> ToolPrompt { - ToolPrompt::Static(self.prompt) + ToolPrompt::Static("echo tool prompt") } } -impl ToolFactory for TestFactory { +impl ToolFactory for EchoFactory { fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { Ok(Arc::new(TestTool { tool_name: self.tool_name, - prompt: self.prompt, + prompt: "echo tool prompt", })) } } -/// Factory that returns a portable echo tool for registry tests. -pub(crate) struct EchoFactory { - /// Tool name passed to [`ToolContext::name`]. - pub(crate) tool_name: &'static str, -} - -impl EchoFactory { - /// Creates a new [`EchoFactory`] with the given tool name. - pub(crate) fn new(name: &'static str) -> Self { - Self { tool_name: name } - } -} - -impl ToolContext for EchoFactory { +impl ToolContext for TestFactory { fn name(&self) -> &'static str { self.tool_name } fn context(&self) -> ToolPrompt { - ToolPrompt::Static("echo tool prompt") + ToolPrompt::Static(self.prompt) } } -impl ToolFactory for EchoFactory { +impl ToolFactory for TestFactory { fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { Ok(Arc::new(TestTool { tool_name: self.tool_name, - prompt: "echo tool prompt", + prompt: self.prompt, })) } } -/// Minimal portable custom tool used by factories above. -struct TestTool { - tool_name: &'static str, - prompt: &'static str, -} - impl ToolContext for TestTool { fn name(&self) -> &'static str { self.tool_name diff --git a/src/reloaded-code-core/src/error.rs b/src/reloaded-code-core/src/error.rs index 9a5b4d5c..c32d5bcf 100644 --- a/src/reloaded-code-core/src/error.rs +++ b/src/reloaded-code-core/src/error.rs @@ -2,6 +2,9 @@ use thiserror::Error; +/// Result type alias for tool operations. +pub type ToolResult = Result; + /// Unified error type for all tool operations. #[derive(Debug, Error)] pub enum ToolError { @@ -65,15 +68,6 @@ pub enum ToolError { }, } -/// Result type alias for tool operations. -pub type ToolResult = Result; - -impl From for ToolError { - fn from(e: globset::Error) -> Self { - ToolError::InvalidPattern(e.to_string()) - } -} - impl ToolError { /// Create a validation error without a specific field. #[must_use] @@ -93,3 +87,9 @@ impl ToolError { } } } + +impl From for ToolError { + fn from(e: globset::Error) -> Self { + ToolError::InvalidPattern(e.to_string()) + } +} diff --git a/src/reloaded-code-core/src/fs/blocking_impl.rs b/src/reloaded-code-core/src/fs/blocking_impl.rs index a296cd9e..63dd6527 100644 --- a/src/reloaded-code-core/src/fs/blocking_impl.rs +++ b/src/reloaded-code-core/src/fs/blocking_impl.rs @@ -3,28 +3,6 @@ use crate::error::ToolResult; use std::path::Path; -/// Reads a file to string. -/// -/// # Errors -/// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, -/// permission denied, or other I/O error). -/// -/// [`ToolError::Io`]: crate::error::ToolError::Io -pub fn read_to_string(path: impl AsRef) -> ToolResult { - Ok(std::fs::read_to_string(path)?) -} - -/// Writes content to a file. -/// -/// # Errors -/// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory -/// does not exist, permission denied, or other I/O error). -/// -/// [`ToolError::Io`]: crate::error::ToolError::Io -pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> ToolResult<()> { - Ok(std::fs::write(path, contents)?) -} - /// Creates a directory and all parent directories. /// /// # Errors @@ -50,3 +28,25 @@ pub fn open_buffered( let file = std::fs::File::open(path)?; Ok(std::io::BufReader::with_capacity(capacity, file)) } + +/// Reads a file to string. +/// +/// # Errors +/// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, +/// permission denied, or other I/O error). +/// +/// [`ToolError::Io`]: crate::error::ToolError::Io +pub fn read_to_string(path: impl AsRef) -> ToolResult { + Ok(std::fs::read_to_string(path)?) +} + +/// Writes content to a file. +/// +/// # Errors +/// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory +/// does not exist, permission denied, or other I/O error). +/// +/// [`ToolError::Io`]: crate::error::ToolError::Io +pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> ToolResult<()> { + Ok(std::fs::write(path, contents)?) +} diff --git a/src/reloaded-code-core/src/fs/mod.rs b/src/reloaded-code-core/src/fs/mod.rs index 786e205c..bbabcfec 100644 --- a/src/reloaded-code-core/src/fs/mod.rs +++ b/src/reloaded-code-core/src/fs/mod.rs @@ -11,16 +11,15 @@ compile_error!("Features tokio and blocking are mutually exclusive"); #[cfg(not(any(feature = "tokio", feature = "blocking")))] compile_error!("Either tokio or blocking feature must be enabled for the fs module"); -#[cfg(feature = "tokio")] -mod tokio_impl; +#[cfg(feature = "blocking")] +pub use blocking_impl::*; #[cfg(feature = "tokio")] pub use tokio_impl::*; #[cfg(feature = "blocking")] mod blocking_impl; -#[cfg(feature = "blocking")] -pub use blocking_impl::*; - +#[cfg(feature = "tokio")] +mod tokio_impl; #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/fs/tokio_impl.rs b/src/reloaded-code-core/src/fs/tokio_impl.rs index b0636cdf..cafa8e6e 100644 --- a/src/reloaded-code-core/src/fs/tokio_impl.rs +++ b/src/reloaded-code-core/src/fs/tokio_impl.rs @@ -3,28 +3,6 @@ use crate::error::ToolResult; use std::path::Path; -/// Reads a file to string. -/// -/// # Errors -/// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, -/// permission denied, or other I/O error). -/// -/// [`ToolError::Io`]: crate::error::ToolError::Io -pub async fn read_to_string(path: impl AsRef) -> ToolResult { - Ok(tokio::fs::read_to_string(path).await?) -} - -/// Writes content to a file. -/// -/// # Errors -/// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory -/// does not exist, permission denied, or other I/O error). -/// -/// [`ToolError::Io`]: crate::error::ToolError::Io -pub async fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> ToolResult<()> { - Ok(tokio::fs::write(path, contents).await?) -} - /// Creates a directory and all parent directories. /// /// # Errors @@ -50,3 +28,25 @@ pub async fn open_buffered( let file = tokio::fs::File::open(path).await?; Ok(tokio::io::BufReader::with_capacity(capacity, file)) } + +/// Reads a file to string. +/// +/// # Errors +/// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, +/// permission denied, or other I/O error). +/// +/// [`ToolError::Io`]: crate::error::ToolError::Io +pub async fn read_to_string(path: impl AsRef) -> ToolResult { + Ok(tokio::fs::read_to_string(path).await?) +} + +/// Writes content to a file. +/// +/// # Errors +/// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory +/// does not exist, permission denied, or other I/O error). +/// +/// [`ToolError::Io`]: crate::error::ToolError::Io +pub async fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> ToolResult<()> { + Ok(tokio::fs::write(path, contents).await?) +} diff --git a/src/reloaded-code-core/src/hooks/mod.rs b/src/reloaded-code-core/src/hooks/mod.rs index 992ce6b9..9e4a4905 100644 --- a/src/reloaded-code-core/src/hooks/mod.rs +++ b/src/reloaded-code-core/src/hooks/mod.rs @@ -25,15 +25,15 @@ //! real tool when the chain is exhausted. Not calling it blocks or replaces the //! tool call. Session hooks remain simple lifecycle events. -mod builder; -mod hook_set; -mod session; -mod tool_hook; - pub use self::builder::HookSetBuilder; pub use self::hook_set::HookSet; pub use self::session::*; pub use self::tool_hook::*; +mod builder; +mod hook_set; +mod session; +mod tool_hook; + /// Max hooks per point before falling back to heap. pub(crate) const INLINE_CAP: usize = 3; diff --git a/src/reloaded-code-core/src/hooks/session.rs b/src/reloaded-code-core/src/hooks/session.rs index ad4c8ded..e73acb53 100644 --- a/src/reloaded-code-core/src/hooks/session.rs +++ b/src/reloaded-code-core/src/hooks/session.rs @@ -1,5 +1,14 @@ //! Session lifecycle event types. +/// Session-compact event callback. +pub type SessionCompactFn = for<'a> fn(&'a SessionContext<'a>); + +/// Session-end event callback. +pub type SessionEndFn = for<'a> fn(&'a SessionContext<'a>, EndReason); + +/// Session-start event callback. +pub type SessionStartFn = for<'a> fn(&'a SessionContext<'a>); + /// Why a session ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EndReason { @@ -18,15 +27,6 @@ pub struct SessionContext<'a> { pub run_id: &'a str, } -/// Session-start event callback. -pub type SessionStartFn = for<'a> fn(&'a SessionContext<'a>); - -/// Session-end event callback. -pub type SessionEndFn = for<'a> fn(&'a SessionContext<'a>, EndReason); - -/// Session-compact event callback. -pub type SessionCompactFn = for<'a> fn(&'a SessionContext<'a>); - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/hooks/tool_hook.rs b/src/reloaded-code-core/src/hooks/tool_hook.rs index c45e98f3..5735f3a8 100644 --- a/src/reloaded-code-core/src/hooks/tool_hook.rs +++ b/src/reloaded-code-core/src/hooks/tool_hook.rs @@ -7,9 +7,6 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -/// Boxed future returned by [`ToolHook::hook`] and [`ToolExecutor::execute`]. -pub type ToolHookFuture<'a> = Pin> + Send + 'a>>; - /// Context passed to each tool hook. #[derive(Debug)] pub struct ToolCallContext<'a> { @@ -21,6 +18,20 @@ pub struct ToolCallContext<'a> { pub run_id: &'a str, } +/// Boxed future returned by [`ToolHook::hook`] and [`ToolExecutor::execute`]. +pub type ToolHookFuture<'a> = Pin> + Send + 'a>>; + +/// Managed trampoline to the next hook or real tool. +/// +/// `ToolOriginal` is consumed by [`call`](Self::call), so normal hooks call +/// the continuation once. Hooks that intentionally retry can clone the +/// request before calling and perform retries around one continuation call. +pub struct ToolOriginal<'a> { + chain: &'a [Arc], + index: usize, + real_tool: &'a dyn ToolExecutor, +} + /// Request passed through the tool hook chain. #[derive(Debug, Clone, PartialEq)] pub struct ToolRequest { @@ -28,38 +39,12 @@ pub struct ToolRequest { pub args: Value, } -impl ToolRequest { - /// Creates a request from JSON arguments. - #[inline] - #[must_use] - pub fn new(args: Value) -> Self { - Self { args } - } -} - -impl From for ToolRequest { - #[inline] - fn from(args: Value) -> Self { - Self::new(args) - } -} - /// Final callable used when the hook chain reaches the real tool. pub trait ToolExecutor: Send + Sync { /// Executes the real tool. fn execute<'a>(&'a self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a>; } -impl ToolExecutor for F -where - F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest) -> ToolHookFuture<'a> + Send + Sync, -{ - #[inline] - fn execute<'a>(&'a self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a> { - self(ctx, req) - } -} - /// Game-style tool hook. /// /// A hook may inspect or change the request, call [`ToolOriginal::call`] to @@ -75,35 +60,6 @@ pub trait ToolHook: Send + Sync + 'static { ) -> ToolHookFuture<'a>; } -impl ToolHook for F -where - F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest, ToolOriginal<'a>) -> ToolHookFuture<'a> - + Send - + Sync - + 'static, -{ - #[inline] - fn hook<'a>( - &'a self, - ctx: &'a ToolCallContext<'a>, - req: ToolRequest, - original: ToolOriginal<'a>, - ) -> ToolHookFuture<'a> { - self(ctx, req, original) - } -} - -/// Managed trampoline to the next hook or real tool. -/// -/// `ToolOriginal` is consumed by [`call`](Self::call), so normal hooks call -/// the continuation once. Hooks that intentionally retry can clone the -/// request before calling and perform retries around one continuation call. -pub struct ToolOriginal<'a> { - chain: &'a [Arc], - index: usize, - real_tool: &'a dyn ToolExecutor, -} - impl<'a> ToolOriginal<'a> { /// Creates a trampoline over the provided hook chain and real tool. #[inline] @@ -135,6 +91,15 @@ impl<'a> ToolOriginal<'a> { } } +impl ToolRequest { + /// Creates a request from JSON arguments. + #[inline] + #[must_use] + pub fn new(args: Value) -> Self { + Self { args } + } +} + impl fmt::Debug for ToolOriginal<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ToolOriginal") @@ -144,6 +109,41 @@ impl fmt::Debug for ToolOriginal<'_> { } } +impl From for ToolRequest { + #[inline] + fn from(args: Value) -> Self { + Self::new(args) + } +} + +impl ToolExecutor for F +where + F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest) -> ToolHookFuture<'a> + Send + Sync, +{ + #[inline] + fn execute<'a>(&'a self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a> { + self(ctx, req) + } +} + +impl ToolHook for F +where + F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest, ToolOriginal<'a>) -> ToolHookFuture<'a> + + Send + + Sync + + 'static, +{ + #[inline] + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + self(ctx, req, original) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/lib.rs b/src/reloaded-code-core/src/lib.rs index 240c19a8..62b8b4ca 100644 --- a/src/reloaded-code-core/src/lib.rs +++ b/src/reloaded-code-core/src/lib.rs @@ -7,27 +7,6 @@ compile_error!("Features `async` and `blocking` are mutually exclusive."); #[cfg(not(any(feature = "async", feature = "blocking")))] compile_error!("Either an async runtime (e.g., `tokio`) or `blocking` feature must be enabled."); -pub mod context; -pub mod credentials; -pub mod custom_tool; -pub mod error; -pub mod fs; -pub mod hooks; -pub mod models; -pub mod output; -pub mod path; -pub mod permissions; -pub mod permissions_ext; -pub mod system_prompt; -pub mod tool_catalog; -pub mod tool_context; -pub mod tool_metadata; -pub mod tools; -pub mod util; -pub mod workspace; - -mod internal; - pub use context::ToolContext; pub use credentials::{CredentialLookup, CredentialResolver}; pub use custom_tool::{ @@ -41,7 +20,6 @@ pub use path::{AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, P pub use system_prompt::SystemPromptBuilder; pub use tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind}; pub use workspace::resolve_workspace_root; - // Re-export tools (always available, sync or async based on runtime feature) pub use tools::{ edit_file, execute_command, execute_command_with_mode, glob_files, grep_search, read_file, @@ -51,13 +29,31 @@ pub use tools::{ ReadSettings, TaskInput, TaskOutput, TaskSettings, Todo, TodoPriority, TodoReadRequest, TodoState, TodoStatus, TodoWriteRequest, WriteRequest, WriteSettings, }; - // Re-export Linux sandbox types (Linux-only, requires linux-bubblewrap feature) #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] pub use tools::linux_bwrap_profile; - // Re-export webfetch tools (requires tokio or blocking feature) #[cfg(any(feature = "tokio", feature = "blocking"))] pub use tools::{ fetch_url, format_json, html_to_markdown, WebFetchOutput, WebFetchRequest, WebFetchSettings, }; + +pub mod context; +pub mod credentials; +pub mod custom_tool; +pub mod error; +pub mod fs; +pub mod hooks; +mod internal; +pub mod models; +pub mod output; +pub mod path; +pub mod permissions; +pub mod permissions_ext; +pub mod system_prompt; +pub mod tool_catalog; +pub mod tool_context; +pub mod tool_metadata; +pub mod tools; +pub mod util; +pub mod workspace; diff --git a/src/reloaded-code-core/src/models/catalog/internal/builder.rs b/src/reloaded-code-core/src/models/catalog/internal/builder.rs index db1f59cf..a04e5f01 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/builder.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/builder.rs @@ -21,14 +21,6 @@ use std::collections::hash_map::Entry as MapEntry; /// Using u8::MAX allows for 256 different hash seeds (0-255). pub const MAX_SEED: u8 = u8::MAX; -#[derive(Debug, Clone, Copy)] -struct ProviderSourceStats { - provider_count: usize, - total_api_url_bytes: usize, - total_env_keys: usize, - total_env_key_bytes: usize, -} - #[derive(Debug, Clone)] struct BuildState { seed: u8, @@ -43,23 +35,12 @@ struct BuildState { has_any_model_config: bool, } -#[inline] -fn build_state_with_capacity( - provider_capacity: usize, - provider_model_capacity: usize, -) -> BuildState { - BuildState { - seed: 0, - hash_state: hash_state_for_seed(0), - provider_table: HashTable::with_capacity(provider_capacity), - provider_model_table: HashTable::with_capacity(provider_model_capacity), - provider_env_ranges: Vec::with_capacity(provider_capacity), - provider_entries: Vec::with_capacity(provider_capacity), - model_entries: Vec::with_capacity(provider_model_capacity), - model_config_entries: Vec::with_capacity(provider_model_capacity), - model_entry_intern: AHashMap::with_capacity(provider_model_capacity), - has_any_model_config: false, - } +#[derive(Debug, Clone, Copy)] +struct ProviderSourceStats { + provider_count: usize, + total_api_url_bytes: usize, + total_env_keys: usize, + total_env_key_bytes: usize, } /// Builds a catalog from provider and provider-model sources. @@ -86,6 +67,126 @@ pub(crate) fn build_from_source( finish_with_source(state, providers, provider_stats) } +#[inline] +fn advance_seed_and_clear(state: &mut BuildState) -> Result<(), ModelCatalogBuildError> { + if state.seed == MAX_SEED { + return Err(ModelCatalogBuildError::HashCollisionExhausted { + attempts: MAX_SEED.into(), + }); + } + + state.seed += 1; + state.hash_state = hash_state_for_seed(state.seed); + clear_entries(state); + Ok(()) +} + +#[inline] +fn analyze_provider_sources( + providers: &[ProviderSource], +) -> Result { + let provider_count = providers.len(); + if provider_count > MAX_PROVIDER_COUNT { + return Err(ModelCatalogBuildError::TooManyProviders { + count: provider_count, + max: MAX_PROVIDER_COUNT, + }); + } + + let mut total_api_url_bytes = 0usize; + let mut total_env_keys = 0usize; + let mut total_env_key_bytes = 0usize; + let max_env_start = usize::from(MAX_ENV_START); + let max_env_count = usize::from(MAX_ENV_RANGE_COUNT); + + for provider in providers { + // SAFETY: total_env_keys is the start index for this provider. + // It must fit the 13-bit PackedEnvRange start field. + if total_env_keys > max_env_start { + return Err(ModelCatalogBuildError::TooManyEnvVarKeys { + count: total_env_keys, + max: max_env_start, + }); + } + + let provider_info = &provider.provider; + let env_count = provider_info.env_vars.len(); + // SAFETY: per-provider count must fit the 3-bit count field. + if env_count > max_env_count { + return Err( + ModelCatalogBuildError::TooManyProviderEnvVarsForOneProvider { + count: env_count, + max: max_env_count, + }, + ); + } + + total_api_url_bytes += provider_info.api_url.len(); + total_env_keys += env_count; + for env_key in &provider_info.env_vars { + total_env_key_bytes += env_key.len(); + } + } + + Ok(ProviderSourceStats { + provider_count, + total_api_url_bytes, + total_env_keys, + total_env_key_bytes, + }) +} + +#[inline] +fn build_state_with_capacity( + provider_capacity: usize, + provider_model_capacity: usize, +) -> BuildState { + BuildState { + seed: 0, + hash_state: hash_state_for_seed(0), + provider_table: HashTable::with_capacity(provider_capacity), + provider_model_table: HashTable::with_capacity(provider_model_capacity), + provider_env_ranges: Vec::with_capacity(provider_capacity), + provider_entries: Vec::with_capacity(provider_capacity), + model_entries: Vec::with_capacity(provider_model_capacity), + model_config_entries: Vec::with_capacity(provider_model_capacity), + model_entry_intern: AHashMap::with_capacity(provider_model_capacity), + has_any_model_config: false, + } +} + +#[inline] +fn finish_with_source( + mut state: BuildState, + providers: &[ProviderSource], + provider_stats: ProviderSourceStats, +) -> Result { + state + .provider_table + .shrink_to_fit(provider_table_entry_hash); + state + .provider_model_table + .shrink_to_fit(provider_model_table_entry_hash); + + let model_config_entries = if state.has_any_model_config { + Some(state.model_config_entries.into_boxed_slice()) + } else { + None + }; + + Ok(ModelCatalog::new( + state.hash_state, + state.provider_table, + state.provider_model_table, + build_provider_api_url_table(providers, provider_stats)?, + build_provider_env_key_table(providers, provider_stats)?, + state.provider_env_ranges.into_boxed_slice(), + state.provider_entries.into_boxed_slice(), + state.model_entries.into_boxed_slice(), + model_config_entries, + )) +} + #[inline] fn populate_tables_once( state: &mut BuildState, @@ -142,6 +243,60 @@ fn populate_tables_once( Ok(()) } +#[inline] +fn build_provider_api_url_table( + providers: &[ProviderSource], + stats: ProviderSourceStats, +) -> Result, ModelCatalogBuildError> { + let mut builder = StringTableBuilder::::with_capacity_in( + stats.provider_count, + stats.total_api_url_bytes, + Global, + ); + + for provider in providers { + builder + .try_push(&provider.provider.api_url) + .map_err(|e| ModelCatalogBuildError::StringTableCapacityExceeded(e.to_string()))?; + } + + Ok(builder.build()) +} + +#[inline] +fn build_provider_env_key_table( + providers: &[ProviderSource], + stats: ProviderSourceStats, +) -> Result, ModelCatalogBuildError> { + let mut builder = StringTableBuilder::::with_capacity_in( + stats.total_env_keys, + stats.total_env_key_bytes, + Global, + ); + + for provider in providers { + for env_key in &provider.provider.env_vars { + builder + .try_push(env_key) + .map_err(|e| ModelCatalogBuildError::StringTableCapacityExceeded(e.to_string()))?; + } + } + + Ok(builder.build()) +} + +#[inline] +fn clear_entries(state: &mut BuildState) { + state.provider_table.clear(); + state.provider_model_table.clear(); + state.provider_env_ranges.clear(); + state.provider_entries.clear(); + state.model_entries.clear(); + state.model_config_entries.clear(); + state.model_entry_intern.clear(); + state.has_any_model_config = false; +} + #[inline] fn insert_provider( state: &mut BuildState, @@ -253,161 +408,6 @@ fn insert_provider_model( Ok(()) } -#[inline] -fn advance_seed_and_clear(state: &mut BuildState) -> Result<(), ModelCatalogBuildError> { - if state.seed == MAX_SEED { - return Err(ModelCatalogBuildError::HashCollisionExhausted { - attempts: MAX_SEED.into(), - }); - } - - state.seed += 1; - state.hash_state = hash_state_for_seed(state.seed); - clear_entries(state); - Ok(()) -} - -#[inline] -fn clear_entries(state: &mut BuildState) { - state.provider_table.clear(); - state.provider_model_table.clear(); - state.provider_env_ranges.clear(); - state.provider_entries.clear(); - state.model_entries.clear(); - state.model_config_entries.clear(); - state.model_entry_intern.clear(); - state.has_any_model_config = false; -} - -#[inline] -fn finish_with_source( - mut state: BuildState, - providers: &[ProviderSource], - provider_stats: ProviderSourceStats, -) -> Result { - state - .provider_table - .shrink_to_fit(provider_table_entry_hash); - state - .provider_model_table - .shrink_to_fit(provider_model_table_entry_hash); - - let model_config_entries = if state.has_any_model_config { - Some(state.model_config_entries.into_boxed_slice()) - } else { - None - }; - - Ok(ModelCatalog::new( - state.hash_state, - state.provider_table, - state.provider_model_table, - build_provider_api_url_table(providers, provider_stats)?, - build_provider_env_key_table(providers, provider_stats)?, - state.provider_env_ranges.into_boxed_slice(), - state.provider_entries.into_boxed_slice(), - state.model_entries.into_boxed_slice(), - model_config_entries, - )) -} - -#[inline] -fn analyze_provider_sources( - providers: &[ProviderSource], -) -> Result { - let provider_count = providers.len(); - if provider_count > MAX_PROVIDER_COUNT { - return Err(ModelCatalogBuildError::TooManyProviders { - count: provider_count, - max: MAX_PROVIDER_COUNT, - }); - } - - let mut total_api_url_bytes = 0usize; - let mut total_env_keys = 0usize; - let mut total_env_key_bytes = 0usize; - let max_env_start = usize::from(MAX_ENV_START); - let max_env_count = usize::from(MAX_ENV_RANGE_COUNT); - - for provider in providers { - // SAFETY: total_env_keys is the start index for this provider. - // It must fit the 13-bit PackedEnvRange start field. - if total_env_keys > max_env_start { - return Err(ModelCatalogBuildError::TooManyEnvVarKeys { - count: total_env_keys, - max: max_env_start, - }); - } - - let provider_info = &provider.provider; - let env_count = provider_info.env_vars.len(); - // SAFETY: per-provider count must fit the 3-bit count field. - if env_count > max_env_count { - return Err( - ModelCatalogBuildError::TooManyProviderEnvVarsForOneProvider { - count: env_count, - max: max_env_count, - }, - ); - } - - total_api_url_bytes += provider_info.api_url.len(); - total_env_keys += env_count; - for env_key in &provider_info.env_vars { - total_env_key_bytes += env_key.len(); - } - } - - Ok(ProviderSourceStats { - provider_count, - total_api_url_bytes, - total_env_keys, - total_env_key_bytes, - }) -} - -#[inline] -fn build_provider_api_url_table( - providers: &[ProviderSource], - stats: ProviderSourceStats, -) -> Result, ModelCatalogBuildError> { - let mut builder = StringTableBuilder::::with_capacity_in( - stats.provider_count, - stats.total_api_url_bytes, - Global, - ); - - for provider in providers { - builder - .try_push(&provider.provider.api_url) - .map_err(|e| ModelCatalogBuildError::StringTableCapacityExceeded(e.to_string()))?; - } - - Ok(builder.build()) -} - -#[inline] -fn build_provider_env_key_table( - providers: &[ProviderSource], - stats: ProviderSourceStats, -) -> Result, ModelCatalogBuildError> { - let mut builder = StringTableBuilder::::with_capacity_in( - stats.total_env_keys, - stats.total_env_key_bytes, - Global, - ); - - for provider in providers { - for env_key in &provider.provider.env_vars { - builder - .try_push(env_key) - .map_err(|e| ModelCatalogBuildError::StringTableCapacityExceeded(e.to_string()))?; - } - } - - Ok(builder.build()) -} - #[cfg(test)] mod tests { use super::build_from_source; diff --git a/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs b/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs index 0b09180a..c35e1da4 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs @@ -4,16 +4,6 @@ use crate::internal::hash64::Hash64; use ahash::RandomState; use core::hash::{BuildHasher, Hasher}; -#[inline(always)] -pub fn provider_table_entry_hash(entry: &super::PackedProviderTableEntry) -> u64 { - entry.hash48() -} - -#[inline(always)] -pub fn provider_model_table_entry_hash(entry: &super::PackedProviderModelTableEntry) -> u64 { - entry.hash48() -} - #[inline(always)] pub fn hash_provider_key(hash_state: &RandomState, provider_key: &str) -> Hash64 { Hash64::from_u64(hash_state.hash_one(provider_key.as_bytes())) @@ -39,3 +29,13 @@ pub fn hash_state_for_seed(seed: u8) -> RandomState { // different RandomState even with the same seed value. RandomState::generate_with(u64::from(seed), 0, 0, 0) } + +#[inline(always)] +pub fn provider_model_table_entry_hash(entry: &super::PackedProviderModelTableEntry) -> u64 { + entry.hash48() +} + +#[inline(always)] +pub fn provider_table_entry_hash(entry: &super::PackedProviderTableEntry) -> u64 { + entry.hash48() +} diff --git a/src/reloaded-code-core/src/models/catalog/internal/mod.rs b/src/reloaded-code-core/src/models/catalog/internal/mod.rs index 13883a74..4ae9a5dd 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/mod.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/mod.rs @@ -5,18 +5,22 @@ pub(crate) use builder::build_from_source; pub use fixed4::Fixed4; - // Re-export hash utilities pub use hash_utils::{ hash_provider_key, hash_provider_model_key, hash_state_for_seed, provider_model_table_entry_hash, provider_table_entry_hash, }; - // Re-export constants needed by the main catalog pub use packed_env_range::{MAX_ENV_RANGE_COUNT, MAX_ENV_START}; pub use packed_model_entry::{MAX_INPUT_TOKENS, MAX_OUTPUT_TOKENS}; pub use packed_provider_model_table_entry::MAX_MODEL_CONFIG_COUNT; pub use packed_provider_table_entry::MAX_PROVIDER_COUNT; +// Re-export internal types for use by the main catalog module +pub use model_config_entry::ModelConfigEntry; +pub use packed_env_range::PackedEnvRange; +pub use packed_model_entry::PackedModelEntry; +pub use packed_provider_model_table_entry::PackedProviderModelTableEntry; +pub use packed_provider_table_entry::PackedProviderTableEntry; mod builder; mod fixed4; @@ -26,10 +30,3 @@ mod packed_env_range; mod packed_model_entry; mod packed_provider_model_table_entry; mod packed_provider_table_entry; - -// Re-export internal types for use by the main catalog module -pub use model_config_entry::ModelConfigEntry; -pub use packed_env_range::PackedEnvRange; -pub use packed_model_entry::PackedModelEntry; -pub use packed_provider_model_table_entry::PackedProviderModelTableEntry; -pub use packed_provider_table_entry::PackedProviderTableEntry; diff --git a/src/reloaded-code-core/src/models/catalog/internal/packed_model_entry.rs b/src/reloaded-code-core/src/models/catalog/internal/packed_model_entry.rs index 4ed98f20..37693f2a 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/packed_model_entry.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/packed_model_entry.rs @@ -8,19 +8,17 @@ use crate::models::catalog::{Modality, ModelInfo}; use bitfields::bitfield; +/// Maximum input token value representable by 29 bits (`536_870_911`). +pub const MAX_INPUT_TOKENS: u32 = (1u32 << MAX_INPUT_BITS) - 1; +/// Maximum output token value representable by 27 bits (`134_217_727`). +pub const MAX_OUTPUT_TOKENS: u32 = (1u32 << MAX_OUTPUT_BITS) - 1; /// Number of bits allocated to modality flags. pub const MODALITY_BITS: u32 = 8; -/// Number of bits allocated to max output tokens. -pub const MAX_OUTPUT_BITS: u32 = 27; +const _: () = assert!(MODALITY_BITS + MAX_OUTPUT_BITS + MAX_INPUT_BITS == 64); /// Number of bits allocated to max input tokens. pub const MAX_INPUT_BITS: u32 = 29; - -/// Maximum output token value representable by 27 bits (`134_217_727`). -pub const MAX_OUTPUT_TOKENS: u32 = (1u32 << MAX_OUTPUT_BITS) - 1; -/// Maximum input token value representable by 29 bits (`536_870_911`). -pub const MAX_INPUT_TOKENS: u32 = (1u32 << MAX_INPUT_BITS) - 1; - -const _: () = assert!(MODALITY_BITS + MAX_OUTPUT_BITS + MAX_INPUT_BITS == 64); +/// Number of bits allocated to max output tokens. +pub const MAX_OUTPUT_BITS: u32 = 27; /// Packed model metadata row. #[bitfield(u64)] diff --git a/src/reloaded-code-core/src/models/catalog/internal/packed_provider_model_table_entry.rs b/src/reloaded-code-core/src/models/catalog/internal/packed_provider_model_table_entry.rs index ed165a73..beb56e79 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/packed_provider_model_table_entry.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/packed_provider_model_table_entry.rs @@ -7,17 +7,15 @@ use crate::models::catalog::public::ModelIdx; use bitfields::bitfield; -/// Number of retained hash bits for provider-model lookup entries. -pub const PROVIDER_MODEL_TABLE_HASH_BITS: u32 = 48; +/// Maximum model-configuration entry count representable by `u16`. +pub const MAX_MODEL_CONFIG_COUNT: usize = (MAX_MODEL_CONFIG_IDX as usize) + 1; /// Bitmask used to truncate hashes to 48 bits. pub const PROVIDER_MODEL_TABLE_HASH_MASK: u64 = (1u64 << PROVIDER_MODEL_TABLE_HASH_BITS) - 1; - +const _: () = assert!(PROVIDER_MODEL_TABLE_HASH_BITS + 16 == 64); /// Maximum model-configuration index representable by `u16`. pub const MAX_MODEL_CONFIG_IDX: u16 = u16::MAX; -/// Maximum model-configuration entry count representable by `u16`. -pub const MAX_MODEL_CONFIG_COUNT: usize = (MAX_MODEL_CONFIG_IDX as usize) + 1; - -const _: () = assert!(PROVIDER_MODEL_TABLE_HASH_BITS + 16 == 64); +/// Number of retained hash bits for provider-model lookup entries. +pub const PROVIDER_MODEL_TABLE_HASH_BITS: u32 = 48; /// Packed provider-model-table entry. #[bitfield(u64)] diff --git a/src/reloaded-code-core/src/models/catalog/internal/packed_provider_table_entry.rs b/src/reloaded-code-core/src/models/catalog/internal/packed_provider_table_entry.rs index 29e47d69..5ce3384f 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/packed_provider_table_entry.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/packed_provider_table_entry.rs @@ -7,17 +7,15 @@ use crate::models::catalog::public::ProviderIdx; use bitfields::bitfield; -/// Number of retained hash bits for provider lookup entries. -pub const PROVIDER_TABLE_HASH_BITS: u32 = 48; +/// Maximum provider count representable by `u16` indices. +pub const MAX_PROVIDER_COUNT: usize = (MAX_PROVIDER_IDX as usize) + 1; /// Bitmask used to truncate hashes to 48 bits. pub const PROVIDER_TABLE_HASH_MASK: u64 = (1u64 << PROVIDER_TABLE_HASH_BITS) - 1; - +const _: () = assert!(PROVIDER_TABLE_HASH_BITS + 16 == 64); /// Maximum provider index representable by `u16`. pub const MAX_PROVIDER_IDX: u16 = u16::MAX; -/// Maximum provider count representable by `u16` indices. -pub const MAX_PROVIDER_COUNT: usize = (MAX_PROVIDER_IDX as usize) + 1; - -const _: () = assert!(PROVIDER_TABLE_HASH_BITS + 16 == 64); +/// Number of retained hash bits for provider lookup entries. +pub const PROVIDER_TABLE_HASH_BITS: u32 = 48; /// Packed provider-table entry. #[bitfield(u64)] diff --git a/src/reloaded-code-core/src/models/catalog/mod.rs b/src/reloaded-code-core/src/models/catalog/mod.rs index 79ae717f..dcfb753b 100644 --- a/src/reloaded-code-core/src/models/catalog/mod.rs +++ b/src/reloaded-code-core/src/models/catalog/mod.rs @@ -122,23 +122,23 @@ //! //! `ProviderTable` (96 entries, 48-bit): //! -//! | Seeds | Odds of failure | -//! | ----- | -------------------: | -//! | 1 | 1 in 62 billion | -//! | 2 | 1 in 3.8 sextillion | -//! | 4 | 1 in 1.5 x 10^43 | -//! | 8 | 1 in 2.1 x 10^86 | -//! | 16 | 1 in 4.4 x 10^172 | +//! | Seeds | Odds of failure | +//! | ----- | ------------------: | +//! | 1 | 1 in 62 billion | +//! | 2 | 1 in 3.8 sextillion | +//! | 4 | 1 in 1.5 x 10^43 | +//! | 8 | 1 in 2.1 x 10^86 | +//! | 16 | 1 in 4.4 x 10^172 | //! //! `ProviderModelTable` (3,031 entries, 48-bit): //! -//! | Seeds | Odds of failure | +//! | Seeds | Odds of failure | //! | ----- | -------------------: | -//! | 1 | 1 in 61 million | +//! | 1 | 1 in 61 million | //! | 2 | 1 in 3.8 quadrillion | -//! | 4 | 1 in 1.4 x 10^31 | -//! | 8 | 1 in 2.0 x 10^62 | -//! | 16 | 1 in 4.0 x 10^124 | +//! | 4 | 1 in 1.4 x 10^31 | +//! | 8 | 1 in 2.0 x 10^62 | +//! | 16 | 1 in 4.0 x 10^124 | //! //! This basically seals the deal, ensuring a collision will never happen. //! @@ -147,16 +147,16 @@ //! //! # Numeric Limits //! -//! | Limit | Value | Description | -//! | ------------------------- | ----------: | ------------------------------------------------ | -//! | Max providers | 65,536 | Addressable by 16-bit provider index | -//! | Max model configs | 65,536 | Addressable by 16-bit model configuration index | -//! | Max provider env vars | 8,192 | Global env-var pool offset (13-bit) | -//! | Max env vars per provider | 7 | Count field in provider range entry (3-bit) | -//! | Max input tokens | 536,870,911 | 29-bit packed field (≈536M) | -//! | Max output tokens | 134,217,727 | 27-bit packed field (≈134M) | -//! | Hash bits retained | 48 | Truncated from 64-bit hash output | -//! | Max reseed attempts | 16 | Number of alternative hash seeds | +//! | Limit | Value | Description | +//! | ------------------------- | ----------: | ----------------------------------------------- | +//! | Max providers | 65,536 | Addressable by 16-bit provider index | +//! | Max model configs | 65,536 | Addressable by 16-bit model configuration index | +//! | Max provider env vars | 8,192 | Global env-var pool offset (13-bit) | +//! | Max env vars per provider | 7 | Count field in provider range entry (3-bit) | +//! | Max input tokens | 536,870,911 | 29-bit packed field (≈536M) | +//! | Max output tokens | 134,217,727 | 27-bit packed field (≈134M) | +//! | Hash bits retained | 48 | Truncated from 64-bit hash output | +//! | Max reseed attempts | 16 | Number of alternative hash seeds | //! //! # Detailed Memory Layout //! @@ -167,22 +167,22 @@ //! //! ## Statistics (models.dev snapshot example) //! -//! | Metric | Value | -//! | ------------------------------------ | ------: | -//! | Unique providers | 96 | -//! | Total model entries | 3,031 | -//! | Unique model configurations | 585 | -//! | Avg models sharing same config | 5.18 | +//! | Metric | Value | +//! | ------------------------------ | ----: | +//! | Unique providers | 96 | +//! | Total model entries | 3,031 | +//! | Unique model configurations | 585 | +//! | Avg models sharing same config | 5.18 | //! //! ## Packed Metadata Storage //! -//! | Field | Type | Size | Count | Total | -//! | ---------------------- | -------------------------------------------- | ---- | ----- | -------: | -//! | `provider_table` | `HashTable` | 8 B | 96 | 768 B | -//! | `provider_model_table` | `HashTable` | 8 B | 3,031 | 24,248 B | -//! | `provider_entries` | `Box<[ProviderType]>` | 1 B | 96 | 96 B | -//! | `model_entries` | `Box<[PackedModelEntry]>` | 8 B | 585 | 4,680 B | -//! | `provider_env_ranges` | `Box<[PackedEnvRange]>` | 2 B | 96 | 192 B | +//! | Field | Type | Size | Count | Total | +//! | ---------------------- | ------------------------------------------ | ---- | ----- | -------: | +//! | `provider_table` | `HashTable` | 8 B | 96 | 768 B | +//! | `provider_model_table` | `HashTable` | 8 B | 3,031 | 24,248 B | +//! | `provider_entries` | `Box<[ProviderType]>` | 1 B | 96 | 96 B | +//! | `model_entries` | `Box<[PackedModelEntry]>` | 8 B | 585 | 4,680 B | +//! | `provider_env_ranges` | `Box<[PackedEnvRange]>` | 2 B | 96 | 192 B | //! //! **Packed metadata total: ~30.0 KB** //! @@ -194,7 +194,7 @@ //! //! | Field | Type | Size | Count | Total | //! | ---------------------- | --------------------------------- | ---- | ----- | ----: | -//! | `model_config_entries` | `Option>` | 4 B | 0 | - | +//! | `model_config_entries` | `Option>` | 4 B | 0 | - | //! //! Alternative model info sources may provide recommended values for these fields. //! @@ -203,10 +203,10 @@ //! Provider API URLs and env-var names are stored in a compact buffer using //! `lite_strtab`. 4GB max size. //! -//! | Field | Type | String Data | Offsets | Total | -//! | ------------------- | ------------------------------- | ----------: | ------: | -------: | -//! | `provider_api_urls` | `StringTable` | 2,460 B | 296 B | 2,756 B | -//! | `provider_env_keys` | `StringTable` | 1,904 B | 436 B | 2,340 B | +//! | Field | Type | String Data | Offsets | Total | +//! | ------------------- | ------------------------------- | ----------: | ------: | ------: | +//! | `provider_api_urls` | `StringTable` | 2,460 B | 296 B | 2,756 B | +//! | `provider_env_keys` | `StringTable` | 1,904 B | 436 B | 2,340 B | //! //! **String tables total: ~5.1 KB** (null-terminated strings + 4-byte offsets) //! @@ -231,10 +231,9 @@ use internal::{ PackedEnvRange, PackedModelEntry, PackedProviderModelTableEntry, PackedProviderTableEntry, }; use lite_strtab::{StringId, StringTable}; -use public::{ProviderEnvVars, INLINE_PROVIDER_ENV_VARS}; - pub use public::builder_types::{ModelCatalogBuildError, ProviderModelSource, ProviderSource}; pub use public::*; +use public::{ProviderEnvVars, INLINE_PROVIDER_ENV_VARS}; mod internal; mod public; diff --git a/src/reloaded-code-core/src/models/catalog/public/builder_types.rs b/src/reloaded-code-core/src/models/catalog/public/builder_types.rs index b1749660..f1c6af95 100644 --- a/src/reloaded-code-core/src/models/catalog/public/builder_types.rs +++ b/src/reloaded-code-core/src/models/catalog/public/builder_types.rs @@ -7,155 +7,6 @@ use super::ProviderIdx; use crate::models::ProviderType; use thiserror::Error; -/// Distilled per-model metadata used when inserting models during catalog construction. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ModelInfo { - /// Content modalities this model can handle as input and/or output. - pub modalities: Modality, - /// Max input tokens. - pub max_input: u32, - /// Max output tokens. - pub max_output: u32, - /// Default sampling temperature, or `None` if unspecified. - pub temperature: Option, - /// Default sampling `top_p`, or `None` if unspecified. - pub top_p: Option, -} - -/// Distilled provider metadata used when inserting providers during catalog construction. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderInfo { - /// Base URL for this provider. Empty when unspecified. - pub api_url: String, - /// Candidate environment variables used to resolve API keys. - /// - /// Order matters: callers may check these in order and use the first match. - pub env_vars: Vec, - /// Type of API used by the provider. - pub api_type: ProviderType, -} - -/// Source that maps a provider key to provider metadata. -/// -/// This wrapper keeps builder input self-documenting and avoids tuple-position -/// ambiguity at call sites. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderSource { - /// Provider identifier used by lookups (for example, `"openai"`). - pub provider_key: String, - /// Provider metadata associated with [`Self::provider_key`]. - pub provider: ProviderInfo, -} - -impl ProviderSource { - /// Creates a provider source. - /// - /// # Parameters - /// - /// * `provider_key` - Provider identifier used during provider lookup. - /// * `provider` - Provider metadata for this key. - /// - /// # Returns - /// - /// A new [`ProviderSource`]. - #[inline] - pub fn new(provider_key: impl Into, provider: ProviderInfo) -> Self { - Self { - provider_key: provider_key.into(), - provider, - } - } -} - -impl From<(String, ProviderInfo)> for ProviderSource { - #[inline] - fn from((provider_key, provider): (String, ProviderInfo)) -> Self { - Self { - provider_key, - provider, - } - } -} - -/// Source that maps a model under a specific provider to model metadata. -/// -/// This wrapper keeps builder input self-documenting and avoids tuple-position -/// ambiguity at call sites. -/// -/// The `model_key` is borrowed because the catalog builder hashes it during -/// construction and does not retain it afterward. Callers must therefore keep -/// the referenced string alive until [`crate::models::catalog::ModelCatalog::build`] -/// returns. -/// -/// The `provider_idx` must correspond to an entry in the `providers` slice passed -/// to [`ModelCatalog::build`]. -/// -/// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build -#[derive(Debug, Clone, PartialEq)] -pub struct ProviderModelSource<'a> { - /// Index into the `providers` slice passed to [`ModelCatalog::build`]. - /// - /// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build - pub provider_idx: ProviderIdx, - /// Borrowed model identifier used by lookups (for example, `"gpt-4"`). - pub model_key: &'a str, - /// Model metadata associated with [`Self::model_key`]. - pub model: ModelInfo, -} - -impl<'a> ProviderModelSource<'a> { - /// Creates a provider model source. - /// - /// # Parameters - /// - /// * `provider_idx` - Index into the `providers` slice passed to [`ModelCatalog::build`]. - /// * `model_key` - Model identifier used during model lookup for this provider. - /// * `model` - Model metadata for this provider model. - /// - /// # Returns - /// - /// A new [`ProviderModelSource`]. - /// - /// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build - #[inline] - pub fn new(provider_idx: ProviderIdx, model_key: &'a str, model: ModelInfo) -> Self { - Self { - provider_idx, - model_key, - model, - } - } -} - -impl<'a> From<(ProviderIdx, &'a str, ModelInfo)> for ProviderModelSource<'a> { - #[inline] - fn from((provider_idx, model_key, model): (ProviderIdx, &'a str, ModelInfo)) -> Self { - Self { - provider_idx, - model_key, - model, - } - } -} - -/// Hash-table kind used in collision/build errors. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LookupTableKind { - /// Provider-key lookup table. - Provider, - /// Provider model lookup table. - ProviderModel, -} - -impl core::fmt::Display for LookupTableKind { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Provider => f.write_str("provider"), - Self::ProviderModel => f.write_str("provider model"), - } - } -} - /// Errors returned when building a [`crate::models::ModelCatalog`]. #[derive(Debug, Error, Clone, PartialEq)] pub enum ModelCatalogBuildError { @@ -251,3 +102,152 @@ pub enum ModelCatalogBuildError { value: f32, }, } + +/// Source that maps a model under a specific provider to model metadata. +/// +/// This wrapper keeps builder input self-documenting and avoids tuple-position +/// ambiguity at call sites. +/// +/// The `model_key` is borrowed because the catalog builder hashes it during +/// construction and does not retain it afterward. Callers must therefore keep +/// the referenced string alive until [`crate::models::catalog::ModelCatalog::build`] +/// returns. +/// +/// The `provider_idx` must correspond to an entry in the `providers` slice passed +/// to [`ModelCatalog::build`]. +/// +/// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderModelSource<'a> { + /// Index into the `providers` slice passed to [`ModelCatalog::build`]. + /// + /// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build + pub provider_idx: ProviderIdx, + /// Borrowed model identifier used by lookups (for example, `"gpt-4"`). + pub model_key: &'a str, + /// Model metadata associated with [`Self::model_key`]. + pub model: ModelInfo, +} + +/// Source that maps a provider key to provider metadata. +/// +/// This wrapper keeps builder input self-documenting and avoids tuple-position +/// ambiguity at call sites. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderSource { + /// Provider identifier used by lookups (for example, `"openai"`). + pub provider_key: String, + /// Provider metadata associated with [`Self::provider_key`]. + pub provider: ProviderInfo, +} + +/// Hash-table kind used in collision/build errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LookupTableKind { + /// Provider-key lookup table. + Provider, + /// Provider model lookup table. + ProviderModel, +} + +/// Distilled per-model metadata used when inserting models during catalog construction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ModelInfo { + /// Content modalities this model can handle as input and/or output. + pub modalities: Modality, + /// Max input tokens. + pub max_input: u32, + /// Max output tokens. + pub max_output: u32, + /// Default sampling temperature, or `None` if unspecified. + pub temperature: Option, + /// Default sampling `top_p`, or `None` if unspecified. + pub top_p: Option, +} + +/// Distilled provider metadata used when inserting providers during catalog construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderInfo { + /// Base URL for this provider. Empty when unspecified. + pub api_url: String, + /// Candidate environment variables used to resolve API keys. + /// + /// Order matters: callers may check these in order and use the first match. + pub env_vars: Vec, + /// Type of API used by the provider. + pub api_type: ProviderType, +} + +impl<'a> ProviderModelSource<'a> { + /// Creates a provider model source. + /// + /// # Parameters + /// + /// * `provider_idx` - Index into the `providers` slice passed to [`ModelCatalog::build`]. + /// * `model_key` - Model identifier used during model lookup for this provider. + /// * `model` - Model metadata for this provider model. + /// + /// # Returns + /// + /// A new [`ProviderModelSource`]. + /// + /// [`ModelCatalog::build`]: crate::models::catalog::ModelCatalog::build + #[inline] + pub fn new(provider_idx: ProviderIdx, model_key: &'a str, model: ModelInfo) -> Self { + Self { + provider_idx, + model_key, + model, + } + } +} + +impl ProviderSource { + /// Creates a provider source. + /// + /// # Parameters + /// + /// * `provider_key` - Provider identifier used during provider lookup. + /// * `provider` - Provider metadata for this key. + /// + /// # Returns + /// + /// A new [`ProviderSource`]. + #[inline] + pub fn new(provider_key: impl Into, provider: ProviderInfo) -> Self { + Self { + provider_key: provider_key.into(), + provider, + } + } +} + +impl<'a> From<(ProviderIdx, &'a str, ModelInfo)> for ProviderModelSource<'a> { + #[inline] + fn from((provider_idx, model_key, model): (ProviderIdx, &'a str, ModelInfo)) -> Self { + Self { + provider_idx, + model_key, + model, + } + } +} + +impl From<(String, ProviderInfo)> for ProviderSource { + #[inline] + fn from((provider_key, provider): (String, ProviderInfo)) -> Self { + Self { + provider_key, + provider, + } + } +} + +impl core::fmt::Display for LookupTableKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Provider => f.write_str("provider"), + Self::ProviderModel => f.write_str("provider model"), + } + } +} diff --git a/src/reloaded-code-core/src/models/catalog/public/entry.rs b/src/reloaded-code-core/src/models/catalog/public/entry.rs index e747381d..4c5e6b94 100644 --- a/src/reloaded-code-core/src/models/catalog/public/entry.rs +++ b/src/reloaded-code-core/src/models/catalog/public/entry.rs @@ -18,45 +18,6 @@ use tinyvec::TinyVec; pub(crate) const INLINE_PROVIDER_ENV_VARS: usize = 2; -pub(crate) type ProviderEnvVars<'a> = TinyVec<[&'a str; INLINE_PROVIDER_ENV_VARS]>; - -/// Provider lookup result. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Provider<'a> { - /// Index into provider metadata tables. - pub provider_idx: ProviderIdx, - /// Provider base URL. - pub api_url: &'a str, - /// Candidate environment variables used to resolve API keys. - env_vars: ProviderEnvVars<'a>, - /// Type of API used by the provider. - pub api_type: ProviderType, -} - -impl<'a> Provider<'a> { - /// Creates a new Provider with the given parameters. - #[inline] - pub(crate) fn new( - provider_idx: ProviderIdx, - api_url: &'a str, - env_vars: ProviderEnvVars<'a>, - api_type: ProviderType, - ) -> Self { - Self { - provider_idx, - api_url, - env_vars, - api_type, - } - } - - /// Returns the candidate environment variables used to resolve API keys. - #[inline] - pub fn env_vars(&self) -> &[&'a str] { - self.env_vars.as_slice() - } -} - /// Model lookup result. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Model { @@ -72,6 +33,21 @@ pub struct Model { top_p: Fixed4, } +/// Provider lookup result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Provider<'a> { + /// Index into provider metadata tables. + pub provider_idx: ProviderIdx, + /// Provider base URL. + pub api_url: &'a str, + /// Candidate environment variables used to resolve API keys. + env_vars: ProviderEnvVars<'a>, + /// Type of API used by the provider. + pub api_type: ProviderType, +} + +pub(crate) type ProviderEnvVars<'a> = TinyVec<[&'a str; INLINE_PROVIDER_ENV_VARS]>; + impl Model { /// Creates a new Model with the given parameters. #[inline] @@ -105,3 +81,27 @@ impl Model { self.top_p.value() } } + +impl<'a> Provider<'a> { + /// Creates a new Provider with the given parameters. + #[inline] + pub(crate) fn new( + provider_idx: ProviderIdx, + api_url: &'a str, + env_vars: ProviderEnvVars<'a>, + api_type: ProviderType, + ) -> Self { + Self { + provider_idx, + api_url, + env_vars, + api_type, + } + } + + /// Returns the candidate environment variables used to resolve API keys. + #[inline] + pub fn env_vars(&self) -> &[&'a str] { + self.env_vars.as_slice() + } +} diff --git a/src/reloaded-code-core/src/models/catalog/public/modality.rs b/src/reloaded-code-core/src/models/catalog/public/modality.rs index f11d2e4d..345b7dd6 100644 --- a/src/reloaded-code-core/src/models/catalog/public/modality.rs +++ b/src/reloaded-code-core/src/models/catalog/public/modality.rs @@ -1,5 +1,3 @@ -use bitflags::bitflags; - bitflags! { /// Content modalities supported by a model. /// @@ -36,6 +34,8 @@ bitflags! { } } +use bitflags::bitflags; + impl Modality { /// Parse a combined modality label. /// diff --git a/src/reloaded-code-core/src/models/catalog/public/model_idx.rs b/src/reloaded-code-core/src/models/catalog/public/model_idx.rs index 648ccd32..946bb5c7 100644 --- a/src/reloaded-code-core/src/models/catalog/public/model_idx.rs +++ b/src/reloaded-code-core/src/models/catalog/public/model_idx.rs @@ -1,5 +1,9 @@ //! Index into model configuration tables. +impl_string_index!(ModelIdx: u16); + +use lite_strtab::impl_string_index; + /// A 16-bit index into model metadata tables. /// /// Used to reference a specific model configuration in the catalog's @@ -41,6 +45,3 @@ impl From for u16 { idx.0 } } - -use lite_strtab::impl_string_index; -impl_string_index!(ModelIdx: u16); diff --git a/src/reloaded-code-core/src/models/catalog/public/provider_idx.rs b/src/reloaded-code-core/src/models/catalog/public/provider_idx.rs index d82121a9..460c00ad 100644 --- a/src/reloaded-code-core/src/models/catalog/public/provider_idx.rs +++ b/src/reloaded-code-core/src/models/catalog/public/provider_idx.rs @@ -1,5 +1,9 @@ //! Index into provider tables. +impl_string_index!(ProviderIdx: u16); + +use lite_strtab::impl_string_index; + /// A 16-bit index into provider metadata tables. /// /// Used to reference a specific provider in the catalog's @@ -41,6 +45,3 @@ impl From for u16 { idx.0 } } - -use lite_strtab::impl_string_index; -impl_string_index!(ProviderIdx: u16); diff --git a/src/reloaded-code-core/src/models/mod.rs b/src/reloaded-code-core/src/models/mod.rs index 495de325..6c9f2fdb 100644 --- a/src/reloaded-code-core/src/models/mod.rs +++ b/src/reloaded-code-core/src/models/mod.rs @@ -1,10 +1,10 @@ //! Compact model catalog for high-performance provider/model lookup. -mod catalog; -mod provider_type; - pub use catalog::{ LookupTableKind, Modality, Model, ModelCatalog, ModelCatalogBuildError, ModelInfo, Provider, ProviderIdx, ProviderInfo, ProviderModelSource, ProviderSource, }; pub use provider_type::ProviderType; + +mod catalog; +mod provider_type; diff --git a/src/reloaded-code-core/src/path/allowed.rs b/src/reloaded-code-core/src/path/allowed.rs index 4e5a9429..e1bc3b8e 100644 --- a/src/reloaded-code-core/src/path/allowed.rs +++ b/src/reloaded-code-core/src/path/allowed.rs @@ -152,6 +152,49 @@ impl PathResolver for AllowedPathResolver { } } +/// For absolute paths, `base.join(input) == input` regardless of base. +/// Canonicalize once, then check all bases - avoids redundant FS calls. +/// +/// Resolution strategy (same as `resolve_relative` but without per-base join): +/// +/// 1. Try `canonicalize()` for existing files - handles symlinks and normalizes. +/// 2. Try `resolve_new_file_fast()` for new files in existing directories. +/// 3. Fall back to `soft_canonicalize()` for paths with missing parent dirs. +/// +/// If the resolved path lands inside any allowed base, accept it. +/// Otherwise, reject with "not within allowed directories". +fn resolve_absolute( + allowed_paths: &[PathBuf], + path: &str, + input_path: &Path, +) -> ToolResult { + // Step 1: canonicalize for existing files - handles symlinks and normalizes. + if let Ok(canonical) = input_path.canonicalize() { + if allowed_paths.iter().any(|base| canonical.starts_with(base)) { + return Ok(canonical); + } + return not_allowed(path); + } + + // Step 2: fast path for new files in existing directories. + if let Some(resolved) = resolve_new_file_fast(input_path) { + if allowed_paths.iter().any(|base| resolved.starts_with(base)) { + return Ok(resolved); + } + return not_allowed(path); + } + + // Step 3: fallback for paths with missing parent dirs. + if let Ok(resolved) = soft_canonicalize(input_path) { + if allowed_paths.iter().any(|base| resolved.starts_with(base)) { + return Ok(resolved); + } + return not_allowed(path); + } + + not_allowed(path) +} + /// For each configured base directory, try to resolve the relative input. /// /// Three resolution tiers, cheapest first: @@ -196,49 +239,6 @@ fn resolve_relative( not_allowed(path) } -/// For absolute paths, `base.join(input) == input` regardless of base. -/// Canonicalize once, then check all bases - avoids redundant FS calls. -/// -/// Resolution strategy (same as `resolve_relative` but without per-base join): -/// -/// 1. Try `canonicalize()` for existing files - handles symlinks and normalizes. -/// 2. Try `resolve_new_file_fast()` for new files in existing directories. -/// 3. Fall back to `soft_canonicalize()` for paths with missing parent dirs. -/// -/// If the resolved path lands inside any allowed base, accept it. -/// Otherwise, reject with "not within allowed directories". -fn resolve_absolute( - allowed_paths: &[PathBuf], - path: &str, - input_path: &Path, -) -> ToolResult { - // Step 1: canonicalize for existing files - handles symlinks and normalizes. - if let Ok(canonical) = input_path.canonicalize() { - if allowed_paths.iter().any(|base| canonical.starts_with(base)) { - return Ok(canonical); - } - return not_allowed(path); - } - - // Step 2: fast path for new files in existing directories. - if let Some(resolved) = resolve_new_file_fast(input_path) { - if allowed_paths.iter().any(|base| resolved.starts_with(base)) { - return Ok(resolved); - } - return not_allowed(path); - } - - // Step 3: fallback for paths with missing parent dirs. - if let Ok(resolved) = soft_canonicalize(input_path) { - if allowed_paths.iter().any(|base| resolved.starts_with(base)) { - return Ok(resolved); - } - return not_allowed(path); - } - - not_allowed(path) -} - #[inline] fn not_allowed(path: &str) -> ToolResult { Err(ToolError::InvalidPath(format!( diff --git a/src/reloaded-code-core/src/path/allowed_glob/mod.rs b/src/reloaded-code-core/src/path/allowed_glob/mod.rs index ae74d868..90444f7a 100644 --- a/src/reloaded-code-core/src/path/allowed_glob/mod.rs +++ b/src/reloaded-code-core/src/path/allowed_glob/mod.rs @@ -28,18 +28,17 @@ //! If no tier succeeds or policy denies the path, reject with //! "not within allowed directories". -pub mod normalize; -mod policy; - use super::{path_analysis, resolve_new_file_fast, PathResolver}; use crate::context::PathMode; use crate::error::{ToolError, ToolResult}; use normalize::{expand_shell, normalize_path}; +pub use policy::{GlobPolicy, GlobPolicyBuilder, RuleAction}; use soft_canonicalize::soft_canonicalize; use std::path::{Path, PathBuf}; use std::sync::Arc; -pub use policy::{GlobPolicy, GlobPolicyBuilder, RuleAction}; +pub mod normalize; +mod policy; /// Path resolver that restricts access to a workspace root with glob pattern filtering. /// @@ -241,11 +240,6 @@ fn resolve_candidate( Err(reject(path)) } -#[inline] -fn reject(path: &str) -> ToolError { - ToolError::InvalidPath(format!("path '{}' is not within allowed directories", path)) -} - /// Validates a resolved path against workspace containment and policy. /// /// Delegates to [`AllowedGlobResolver::is_path_allowed`] for a single source @@ -263,6 +257,11 @@ fn validate_resolved( } } +#[inline] +fn reject(path: &str) -> ToolError { + ToolError::InvalidPath(format!("path '{}' is not within allowed directories", path)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/path/allowed_glob/normalize.rs b/src/reloaded-code-core/src/path/allowed_glob/normalize.rs index 9b9bb5c5..d191fb3e 100644 --- a/src/reloaded-code-core/src/path/allowed_glob/normalize.rs +++ b/src/reloaded-code-core/src/path/allowed_glob/normalize.rs @@ -1,9 +1,37 @@ //! Path normalization utilities for glob matching. +use crate::error::{ToolError, ToolResult}; use std::borrow::Cow; use std::path::{Path, PathBuf}; -use crate::error::{ToolError, ToolResult}; +/// Expands shell-like patterns in a path string, returning a [`PathBuf`]. +/// +/// Wraps the internal expansion logic with fail-fast error handling: returns +/// `ToolError::InvalidPath` if expansion fails (e.g., unset variable). +/// +/// # Errors +/// - Returns [`ToolError::InvalidPath`] when shell expansion fails (e.g., unset +/// environment variable in the path pattern). +pub fn expand_shell(path: &str) -> ToolResult { + expand_pattern(path) + .map(|cow| PathBuf::from(cow.into_owned())) + .map_err(|e| { + ToolError::InvalidPath(format!( + "failed to expand shell pattern in path '{}': {}", + path, e + )) + }) +} + +/// Expands shell-like patterns (`~/`, `$HOME/`, `$VAR`, `${VAR:-default}`). +/// +/// Returns `Cow::Borrowed` for patterns without shell metacharacters (zero allocation). +/// All other `expand_*` functions in this crate are thin wrappers around this one. +pub(crate) fn expand_pattern( + pattern: &str, +) -> Result, shellexpand::LookupError> { + shellexpand::full(pattern) +} /// Normalizes a path to use forward slashes for consistent glob matching. /// @@ -26,35 +54,6 @@ pub(crate) fn normalize_path(path: &Path) -> Cow<'_, str> { } } -/// Expands shell-like patterns (`~/`, `$HOME/`, `$VAR`, `${VAR:-default}`). -/// -/// Returns `Cow::Borrowed` for patterns without shell metacharacters (zero allocation). -/// All other `expand_*` functions in this crate are thin wrappers around this one. -pub(crate) fn expand_pattern( - pattern: &str, -) -> Result, shellexpand::LookupError> { - shellexpand::full(pattern) -} - -/// Expands shell-like patterns in a path string, returning a [`PathBuf`]. -/// -/// Wraps the internal expansion logic with fail-fast error handling: returns -/// `ToolError::InvalidPath` if expansion fails (e.g., unset variable). -/// -/// # Errors -/// - Returns [`ToolError::InvalidPath`] when shell expansion fails (e.g., unset -/// environment variable in the path pattern). -pub fn expand_shell(path: &str) -> ToolResult { - expand_pattern(path) - .map(|cow| PathBuf::from(cow.into_owned())) - .map_err(|e| { - ToolError::InvalidPath(format!( - "failed to expand shell pattern in path '{}': {}", - path, e - )) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/path/allowed_glob/policy.rs b/src/reloaded-code-core/src/path/allowed_glob/policy.rs index b95095ce..0cd71276 100644 --- a/src/reloaded-code-core/src/path/allowed_glob/policy.rs +++ b/src/reloaded-code-core/src/path/allowed_glob/policy.rs @@ -29,15 +29,6 @@ use crate::error::{ToolError, ToolResult}; use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use std::path::{Path, PathBuf}; -/// Action to take when a glob pattern matches. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuleAction { - /// Allow access to the matched path. - Allow, - /// Deny access to the matched path. - Deny, -} - /// Glob pattern policy for path resolution. /// /// Patterns are evaluated with **last-match-wins** precedence using reverse @@ -63,13 +54,22 @@ pub struct GlobPolicy { glob_set: GlobSet, } -impl std::fmt::Debug for GlobPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GlobPolicy") - .field("rules_count", &self.rules.len()) - .field("glob_set", &self.glob_set) - .finish() - } +/// Builder for constructing [`GlobPolicy`] instances. +#[derive(Debug)] +pub struct GlobPolicyBuilder { + /// Optional workspace root. When set, relative patterns are joined with + /// this path before compilation. + base_path: Option, + rules: Vec<(Glob, RuleAction)>, +} + +/// Action to take when a glob pattern matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleAction { + /// Allow access to the matched path. + Allow, + /// Deny access to the matched path. + Deny, } impl GlobPolicy { @@ -149,25 +149,6 @@ impl GlobPolicy { } } -/// Builder for constructing [`GlobPolicy`] instances. -#[derive(Debug)] -pub struct GlobPolicyBuilder { - /// Optional workspace root. When set, relative patterns are joined with - /// this path before compilation. - base_path: Option, - rules: Vec<(Glob, RuleAction)>, -} - -#[allow(clippy::derivable_impls)] // Explicit impl for clarity; base_path=None is required by spec -impl Default for GlobPolicyBuilder { - fn default() -> Self { - Self { - base_path: None, - rules: Vec::new(), - } - } -} - impl GlobPolicyBuilder { /// Creates a new empty policy builder. pub fn new() -> Self { @@ -327,6 +308,25 @@ impl GlobPolicyBuilder { } } +impl std::fmt::Debug for GlobPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GlobPolicy") + .field("rules_count", &self.rules.len()) + .field("glob_set", &self.glob_set) + .finish() + } +} + +#[allow(clippy::derivable_impls)] // Explicit impl for clarity; base_path=None is required by spec +impl Default for GlobPolicyBuilder { + fn default() -> Self { + Self { + base_path: None, + rules: Vec::new(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/path/mod.rs b/src/reloaded-code-core/src/path/mod.rs index b8df6d8f..d97b0bb0 100644 --- a/src/reloaded-code-core/src/path/mod.rs +++ b/src/reloaded-code-core/src/path/mod.rs @@ -5,19 +5,24 @@ //! - [`AllowedPathResolver`] - Restricts to allowed directories //! - [`AllowedGlobResolver`] - Restricts to allowed directories with glob pattern filtering -mod absolute; -mod allowed; -pub mod allowed_glob; - +use crate::context::PathMode; +use crate::error::ToolResult; pub use absolute::AbsolutePathResolver; pub use allowed::AllowedPathResolver; pub use allowed_glob::normalize::expand_shell; pub use allowed_glob::{AllowedGlobResolver, GlobPolicy, GlobPolicyBuilder, RuleAction}; - -use crate::context::PathMode; -use crate::error::ToolResult; use std::path::{Component, Path, PathBuf}; +mod absolute; +mod allowed; +pub mod allowed_glob; + +/// Result of analyzing a path for traversal attacks. +pub(crate) struct PathAnalysis { + /// Whether the path would escape its base directory. + pub(crate) escapes: bool, +} + /// Strategy for resolving and validating file paths. /// /// Implementations control whether paths must be absolute, relative to @@ -32,11 +37,13 @@ pub trait PathResolver: Send + Sync { /// Fast per-entry check: is this absolute path allowed? /// /// WalkBuilder yields real absolute paths. No canonicalization needed. - /// Used by glob/grep to filter walked entries without [`resolve()`](Self::resolve) + /// Used by glob/grep to filter walked entries without [`resolve()`] /// overhead. /// - /// Implementations must ensure paths where [`resolve()`](Self::resolve) succeeds + /// Implementations must ensure paths where [`resolve()`] succeeds /// also satisfy `is_path_allowed()` - the two must be consistent. + /// + /// [`resolve()`]: Self::resolve fn is_path_allowed(&self, path: &Path) -> bool; /// Returns the path mode for this resolver instance. @@ -60,43 +67,6 @@ pub(crate) fn relative_path_escapes_base(path: &Path) -> bool { path_analysis(path).escapes } -/// Result of analyzing a path for traversal attacks. -pub(crate) struct PathAnalysis { - /// Whether the path would escape its base directory. - pub(crate) escapes: bool, -} - -/// Analyzes a path for traversal attacks. -/// -/// This is a single-pass analysis that checks whether the path escapes -/// its base directory (for security). -#[inline] -pub(crate) fn path_analysis(path: &Path) -> PathAnalysis { - if path.is_absolute() { - return PathAnalysis { escapes: false }; - } - - let mut depth = 0usize; - - for component in path.components() { - match component { - Component::Normal(_) => depth += 1, - Component::CurDir => {} - Component::ParentDir => { - if depth == 0 { - return PathAnalysis { escapes: true }; - } - depth -= 1; - } - Component::RootDir | Component::Prefix(_) => { - return PathAnalysis { escapes: false }; - } - } - } - - PathAnalysis { escapes: false } -} - /// Resolves a path for a new file when the parent directory exists. /// /// This is a fast path optimization that avoids the expensive `soft_canonicalize` @@ -135,3 +105,34 @@ pub(crate) fn resolve_new_file_fast(candidate: &Path) -> Option { None } } + +/// Analyzes a path for traversal attacks. +/// +/// This is a single-pass analysis that checks whether the path escapes +/// its base directory (for security). +#[inline] +pub(crate) fn path_analysis(path: &Path) -> PathAnalysis { + if path.is_absolute() { + return PathAnalysis { escapes: false }; + } + + let mut depth = 0usize; + + for component in path.components() { + match component { + Component::Normal(_) => depth += 1, + Component::CurDir => {} + Component::ParentDir => { + if depth == 0 { + return PathAnalysis { escapes: true }; + } + depth -= 1; + } + Component::RootDir | Component::Prefix(_) => { + return PathAnalysis { escapes: false }; + } + } + } + + PathAnalysis { escapes: false } +} diff --git a/src/reloaded-code-core/src/permissions.rs b/src/reloaded-code-core/src/permissions.rs index 1d625ea4..a124135d 100644 --- a/src/reloaded-code-core/src/permissions.rs +++ b/src/reloaded-code-core/src/permissions.rs @@ -65,16 +65,15 @@ use std::borrow::Cow; /// (e.g., `$HOME` is unset). pub type ExpandError = shellexpand::LookupError; -/// Permission level for tool access. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -#[repr(u8)] -pub enum PermissionAction { - /// Tool is denied. - #[default] - Deny = 0, - /// Tool is allowed. - Allow = 1, +/// Ordered ruleset for permission evaluation. Last matching rule wins. +/// +/// # Default Behavior +/// +/// When no rule matches, the default action is [`PermissionAction::Deny`]. +/// To allow a permission, you must explicitly add an allow rule. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Ruleset { + rules: Vec, } /// A single permission rule with pattern-based matching. @@ -111,99 +110,16 @@ pub struct Rule { action: PermissionAction, } -impl Rule { - /// Creates a new rule with the provided permission and pattern. - /// - /// Permission keys with `*` or `?` are treated as patterns. - /// `*` matches any number of characters (including none), and `?` - /// matches exactly one. - /// - /// # Examples - /// - /// ``` - /// use reloaded_code_core::permissions::{Rule, PermissionAction}; - /// - /// // Exact match on permission key - /// let exact = Rule::new("bash", "*", PermissionAction::Allow).unwrap(); - /// - /// // Wildcard permission key matches any tool - /// let wildcard = Rule::new("*", "*", PermissionAction::Allow).unwrap(); - /// ``` - pub fn new( - permission: impl Into>, - pattern: impl Into>, - action: PermissionAction, - ) -> Result { - let permission = permission.into(); - let pattern_box: Box = pattern.into(); - let pattern: Box = match expand_pattern(&pattern_box) { - Ok(Cow::Borrowed(_)) => pattern_box, - Ok(Cow::Owned(s)) => s.into_boxed_str(), - Err(e) => return Err(e), - }; - Ok(Self { - permission_hash: hash_u64(&permission), - pattern_hash: hash_u64(&pattern), - permission_is_wildcard: permission.contains('*') || permission.contains('?'), - pattern_is_wildcard: pattern.contains('*') || pattern.contains('?'), - permission, - pattern, - action, - }) - } - - /// Returns the permission key pattern. - #[inline] - pub fn permission(&self) -> &str { - &self.permission - } - - /// Returns the stored pattern. - #[inline] - pub fn pattern(&self) -> &str { - &self.pattern - } - - /// Returns the action for this rule. - #[inline] - pub fn action(&self) -> PermissionAction { - self.action - } - - /// Returns the stored 64-bit permission hash. - #[inline] - pub fn permission_hash(&self) -> u64 { - self.permission_hash.as_u64() - } - - /// Returns the stored 64-bit pattern hash. - #[inline] - pub fn pattern_hash(&self) -> u64 { - self.pattern_hash.as_u64() - } - - /// Returns true if the permission key contains wildcards. - #[inline] - pub fn permission_is_wildcard(&self) -> bool { - self.permission_is_wildcard - } - - /// Returns true if the pattern contains wildcards. - #[inline] - pub fn pattern_is_wildcard(&self) -> bool { - self.pattern_is_wildcard - } -} - -/// Ordered ruleset for permission evaluation. Last matching rule wins. -/// -/// # Default Behavior -/// -/// When no rule matches, the default action is [`PermissionAction::Deny`]. -/// To allow a permission, you must explicitly add an allow rule. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Ruleset { - rules: Vec, +/// Permission level for tool access. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[repr(u8)] +pub enum PermissionAction { + /// Tool is denied. + #[default] + Deny = 0, + /// Tool is allowed. + Allow = 1, } impl Ruleset { @@ -329,6 +245,90 @@ impl Ruleset { } } +impl Rule { + /// Creates a new rule with the provided permission and pattern. + /// + /// Permission keys with `*` or `?` are treated as patterns. + /// `*` matches any number of characters (including none), and `?` + /// matches exactly one. + /// + /// # Examples + /// + /// ``` + /// use reloaded_code_core::permissions::{Rule, PermissionAction}; + /// + /// // Exact match on permission key + /// let exact = Rule::new("bash", "*", PermissionAction::Allow).unwrap(); + /// + /// // Wildcard permission key matches any tool + /// let wildcard = Rule::new("*", "*", PermissionAction::Allow).unwrap(); + /// ``` + pub fn new( + permission: impl Into>, + pattern: impl Into>, + action: PermissionAction, + ) -> Result { + let permission = permission.into(); + let pattern_box: Box = pattern.into(); + let pattern: Box = match expand_pattern(&pattern_box) { + Ok(Cow::Borrowed(_)) => pattern_box, + Ok(Cow::Owned(s)) => s.into_boxed_str(), + Err(e) => return Err(e), + }; + Ok(Self { + permission_hash: hash_u64(&permission), + pattern_hash: hash_u64(&pattern), + permission_is_wildcard: permission.contains('*') || permission.contains('?'), + pattern_is_wildcard: pattern.contains('*') || pattern.contains('?'), + permission, + pattern, + action, + }) + } + + /// Returns the permission key pattern. + #[inline] + pub fn permission(&self) -> &str { + &self.permission + } + + /// Returns the stored pattern. + #[inline] + pub fn pattern(&self) -> &str { + &self.pattern + } + + /// Returns the action for this rule. + #[inline] + pub fn action(&self) -> PermissionAction { + self.action + } + + /// Returns the stored 64-bit permission hash. + #[inline] + pub fn permission_hash(&self) -> u64 { + self.permission_hash.as_u64() + } + + /// Returns the stored 64-bit pattern hash. + #[inline] + pub fn pattern_hash(&self) -> u64 { + self.pattern_hash.as_u64() + } + + /// Returns true if the permission key contains wildcards. + #[inline] + pub fn permission_is_wildcard(&self) -> bool { + self.permission_is_wildcard + } + + /// Returns true if the pattern contains wildcards. + #[inline] + pub fn pattern_is_wildcard(&self) -> bool { + self.pattern_is_wildcard + } +} + /// Matches a string against a wildcard pattern. /// /// `*` matches any number of characters (including none), and `?` @@ -356,6 +356,32 @@ pub(crate) fn wildcard_match(input: &str, pattern: &str) -> bool { wildcard_match_impl(input.as_bytes(), pattern.as_bytes()) } +#[inline(always)] +fn evaluate_single_rule(rule: &Rule, permission: &str, subject: &str) -> PermissionAction { + let permission_hash = hash_u64(permission); + if !rule_matches( + permission, + permission_hash, + &rule.permission, + rule.permission_hash, + rule.permission_is_wildcard, + ) { + return PermissionAction::Deny; + } + + let pattern_matches = if rule.pattern_is_wildcard { + wildcard_match(subject, &rule.pattern) + } else { + rule.pattern_hash == hash_u64(subject) && &*rule.pattern == subject + }; + + if pattern_matches { + rule.action + } else { + PermissionAction::Deny + } +} + /// Recursive wildcard matching implementation. /// /// Uses byte slices for efficiency. Handles `*` and `?` wildcards. @@ -415,32 +441,6 @@ fn rule_matches( } } -#[inline(always)] -fn evaluate_single_rule(rule: &Rule, permission: &str, subject: &str) -> PermissionAction { - let permission_hash = hash_u64(permission); - if !rule_matches( - permission, - permission_hash, - &rule.permission, - rule.permission_hash, - rule.permission_is_wildcard, - ) { - return PermissionAction::Deny; - } - - let pattern_matches = if rule.pattern_is_wildcard { - wildcard_match(subject, &rule.pattern) - } else { - rule.pattern_hash == hash_u64(subject) && &*rule.pattern == subject - }; - - if pattern_matches { - rule.action - } else { - PermissionAction::Deny - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/system_prompt.rs b/src/reloaded-code-core/src/system_prompt.rs index 52cd1ef5..e1ff5334 100644 --- a/src/reloaded-code-core/src/system_prompt.rs +++ b/src/reloaded-code-core/src/system_prompt.rs @@ -8,12 +8,6 @@ use crate::context::{ }; use crate::path::AllowedPathResolver; -/// Entry storing a tool name and prompt renderer. -struct ContextEntry { - name: &'static str, - prompt: ToolPrompt, -} - /// Builder that tracks tools and generates formatted system prompts. /// /// The environment section is always included and appears before tool listings. @@ -72,6 +66,12 @@ pub struct SystemPromptBuilder { system_prompt: Option, } +/// Entry storing a tool name and prompt renderer. +struct ContextEntry { + name: &'static str, + prompt: ToolPrompt, +} + impl SystemPromptBuilder { /// Creates a new system prompt builder. #[inline] diff --git a/src/reloaded-code-core/src/tool_catalog.rs b/src/reloaded-code-core/src/tool_catalog.rs index f42ad22d..8f18e4b4 100644 --- a/src/reloaded-code-core/src/tool_catalog.rs +++ b/src/reloaded-code-core/src/tool_catalog.rs @@ -10,6 +10,19 @@ use crate::tool_metadata::{ webfetch as webfetch_meta, write as write_meta, }; +const DEFAULT_TOOLS: [ToolCatalogEntry; 10] = [ + ToolCatalogEntry::new(read_meta::NAME, ToolCatalogKind::Read), + ToolCatalogEntry::new(write_meta::NAME, ToolCatalogKind::Write), + ToolCatalogEntry::new(edit_meta::NAME, ToolCatalogKind::Edit), + ToolCatalogEntry::new(glob_meta::NAME, ToolCatalogKind::Glob), + ToolCatalogEntry::new(grep_meta::NAME, ToolCatalogKind::Grep), + ToolCatalogEntry::new(bash_meta::NAME, ToolCatalogKind::Bash), + ToolCatalogEntry::new(webfetch_meta::NAME, ToolCatalogKind::WebFetch), + ToolCatalogEntry::new(todo_read_meta::NAME, ToolCatalogKind::TodoRead), + ToolCatalogEntry::new(todo_write_meta::NAME, ToolCatalogKind::TodoWrite), + ToolCatalogEntry::new(task_meta::NAME, ToolCatalogKind::Task), +]; + /// One tool an integration can provide. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ToolCatalogEntry { @@ -19,14 +32,6 @@ pub struct ToolCatalogEntry { pub kind: ToolCatalogKind, } -impl ToolCatalogEntry { - /// Creates a tool entry from its name and kind. - #[must_use] - pub const fn new(name: &'static str, kind: ToolCatalogKind) -> Self { - Self { name, kind } - } -} - /// Standard and custom tool kinds understood by reloaded-code adapters. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -55,18 +60,13 @@ pub enum ToolCatalogKind { Custom, } -const DEFAULT_TOOLS: [ToolCatalogEntry; 10] = [ - ToolCatalogEntry::new(read_meta::NAME, ToolCatalogKind::Read), - ToolCatalogEntry::new(write_meta::NAME, ToolCatalogKind::Write), - ToolCatalogEntry::new(edit_meta::NAME, ToolCatalogKind::Edit), - ToolCatalogEntry::new(glob_meta::NAME, ToolCatalogKind::Glob), - ToolCatalogEntry::new(grep_meta::NAME, ToolCatalogKind::Grep), - ToolCatalogEntry::new(bash_meta::NAME, ToolCatalogKind::Bash), - ToolCatalogEntry::new(webfetch_meta::NAME, ToolCatalogKind::WebFetch), - ToolCatalogEntry::new(todo_read_meta::NAME, ToolCatalogKind::TodoRead), - ToolCatalogEntry::new(todo_write_meta::NAME, ToolCatalogKind::TodoWrite), - ToolCatalogEntry::new(task_meta::NAME, ToolCatalogKind::Task), -]; +impl ToolCatalogEntry { + /// Creates a tool entry from its name and kind. + #[must_use] + pub const fn new(name: &'static str, kind: ToolCatalogKind) -> Self { + Self { name, kind } + } +} /// Returns the standard tool set. #[must_use] diff --git a/src/reloaded-code-core/src/tool_context/mod.rs b/src/reloaded-code-core/src/tool_context/mod.rs index dc5432a0..4abade83 100644 --- a/src/reloaded-code-core/src/tool_context/mod.rs +++ b/src/reloaded-code-core/src/tool_context/mod.rs @@ -4,10 +4,9 @@ //! when constructing tools. Create one instance before the tool construction //! loop and pass it to each tool resolver. -use std::path::{Path, PathBuf}; - use crate::permissions::Ruleset; use soft_canonicalize::soft_canonicalize; +use std::path::{Path, PathBuf}; /// Context passed when building any tool. /// diff --git a/src/reloaded-code-core/src/tool_metadata/bash.rs b/src/reloaded-code-core/src/tool_metadata/bash.rs index d6d5ef77..c3e2a86b 100644 --- a/src/reloaded-code-core/src/tool_metadata/bash.rs +++ b/src/reloaded-code-core/src/tool_metadata/bash.rs @@ -2,18 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "bash"; - -/// Default timeout in milliseconds. -pub const DEFAULT_TIMEOUT_MS: u32 = 120_000; - -/// Maximum timeout in milliseconds. -pub const MAX_TIMEOUT_MS: u32 = 600_000; - -/// Tool description. -pub const DESCRIPTION: &str = "Run a shell command in a fresh process."; - /// Parameter metadata. pub mod param { use super::{ParamMetadata, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS}; @@ -40,3 +28,12 @@ pub mod param { false, ); } + +/// Default timeout in milliseconds. +pub const DEFAULT_TIMEOUT_MS: u32 = 120_000; +/// Tool description. +pub const DESCRIPTION: &str = "Run a shell command in a fresh process."; +/// Maximum timeout in milliseconds. +pub const MAX_TIMEOUT_MS: u32 = 600_000; +/// Canonical tool name. +pub const NAME: &str = "bash"; diff --git a/src/reloaded-code-core/src/tool_metadata/edit.rs b/src/reloaded-code-core/src/tool_metadata/edit.rs index a4591222..56c3fedb 100644 --- a/src/reloaded-code-core/src/tool_metadata/edit.rs +++ b/src/reloaded-code-core/src/tool_metadata/edit.rs @@ -2,18 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "edit"; - -/// Default value for `replace_all`. -pub const DEFAULT_REPLACE_ALL: bool = false; - -/// Serde-friendly default helper for `replace_all`. -#[must_use] -pub const fn default_replace_all() -> bool { - DEFAULT_REPLACE_ALL -} - /// Tool descriptions. pub mod description { /// Absolute-path variant. @@ -24,7 +12,6 @@ pub mod description { pub const ALLOWED: &str = "Replace exact text in a file in allowed directories. Without replace_all, old_string must match exactly once."; } - /// Parameter metadata. pub mod param { use super::ParamMetadata; @@ -55,3 +42,14 @@ pub mod param { false, ); } + +/// Default value for `replace_all`. +pub const DEFAULT_REPLACE_ALL: bool = false; +/// Canonical tool name. +pub const NAME: &str = "edit"; + +/// Serde-friendly default helper for `replace_all`. +#[must_use] +pub const fn default_replace_all() -> bool { + DEFAULT_REPLACE_ALL +} diff --git a/src/reloaded-code-core/src/tool_metadata/glob.rs b/src/reloaded-code-core/src/tool_metadata/glob.rs index caf1543c..40c6b0e5 100644 --- a/src/reloaded-code-core/src/tool_metadata/glob.rs +++ b/src/reloaded-code-core/src/tool_metadata/glob.rs @@ -2,12 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "glob"; - -/// Maximum number of results returned. -pub const MAX_RESULTS: usize = 1000; - /// Tool descriptions. pub mod description { /// Absolute-path variant. @@ -18,7 +12,6 @@ pub mod description { pub const ALLOWED: &str = "Find files by glob pattern in allowed directories. Respects .gitignore and sorts newest first."; } - /// Parameter metadata. pub mod param { use super::ParamMetadata; @@ -41,3 +34,8 @@ pub mod param { true, ); } + +/// Maximum number of results returned. +pub const MAX_RESULTS: usize = 1000; +/// Canonical tool name. +pub const NAME: &str = "glob"; diff --git a/src/reloaded-code-core/src/tool_metadata/grep.rs b/src/reloaded-code-core/src/tool_metadata/grep.rs index dabbdf47..0b181f63 100644 --- a/src/reloaded-code-core/src/tool_metadata/grep.rs +++ b/src/reloaded-code-core/src/tool_metadata/grep.rs @@ -2,15 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "grep"; - -/// Default maximum matches to return. -pub const DEFAULT_LIMIT: usize = 100; - -/// Maximum allowed matches to return. -pub const MAX_LIMIT: usize = 2000; - /// Tool descriptions. pub mod description { /// Absolute-path variant. @@ -33,7 +24,6 @@ pub mod description { } } } - /// Parameter metadata. pub mod param { use super::{ParamMetadata, DEFAULT_LIMIT, MAX_LIMIT}; @@ -71,3 +61,10 @@ pub mod param { false, ); } + +/// Default maximum matches to return. +pub const DEFAULT_LIMIT: usize = 100; +/// Maximum allowed matches to return. +pub const MAX_LIMIT: usize = 2000; +/// Canonical tool name. +pub const NAME: &str = "grep"; diff --git a/src/reloaded-code-core/src/tool_metadata/mod.rs b/src/reloaded-code-core/src/tool_metadata/mod.rs index 369d1944..031a36b1 100644 --- a/src/reloaded-code-core/src/tool_metadata/mod.rs +++ b/src/reloaded-code-core/src/tool_metadata/mod.rs @@ -15,6 +15,24 @@ pub mod todo_read; pub mod todo_write; pub mod webfetch; pub mod write; +/// Backward-compatible flat description exports. +pub mod descriptions { + pub use super::bash::DESCRIPTION as BASH; + pub use super::edit::description::ABSOLUTE as EDIT_ABSOLUTE; + pub use super::edit::description::ALLOWED as EDIT_ALLOWED; + pub use super::glob::description::ABSOLUTE as GLOB_ABSOLUTE; + pub use super::glob::description::ALLOWED as GLOB_ALLOWED; + pub use super::grep::description::absolute as grep_absolute; + pub use super::grep::description::allowed as grep_allowed; + pub use super::read::description::absolute as read_absolute; + pub use super::read::description::allowed as read_allowed; + pub use super::task::DESCRIPTION_PREFIX as TASK_PREFIX; + pub use super::todo_read::DESCRIPTION as TODO_READ; + pub use super::todo_write::DESCRIPTION as TODO_WRITE; + pub use super::webfetch::DESCRIPTION as WEBFETCH; + pub use super::write::description::ABSOLUTE as WRITE_ABSOLUTE; + pub use super::write::description::ALLOWED as WRITE_ALLOWED; +} /// Shared parameter metadata for provider-facing tool schemas. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,25 +57,6 @@ impl ParamMetadata { } } -/// Backward-compatible flat description exports. -pub mod descriptions { - pub use super::bash::DESCRIPTION as BASH; - pub use super::edit::description::ABSOLUTE as EDIT_ABSOLUTE; - pub use super::edit::description::ALLOWED as EDIT_ALLOWED; - pub use super::glob::description::ABSOLUTE as GLOB_ABSOLUTE; - pub use super::glob::description::ALLOWED as GLOB_ALLOWED; - pub use super::grep::description::absolute as grep_absolute; - pub use super::grep::description::allowed as grep_allowed; - pub use super::read::description::absolute as read_absolute; - pub use super::read::description::allowed as read_allowed; - pub use super::task::DESCRIPTION_PREFIX as TASK_PREFIX; - pub use super::todo_read::DESCRIPTION as TODO_READ; - pub use super::todo_write::DESCRIPTION as TODO_WRITE; - pub use super::webfetch::DESCRIPTION as WEBFETCH; - pub use super::write::description::ABSOLUTE as WRITE_ABSOLUTE; - pub use super::write::description::ALLOWED as WRITE_ALLOWED; -} - #[cfg(test)] mod tests { use super::{ diff --git a/src/reloaded-code-core/src/tool_metadata/read.rs b/src/reloaded-code-core/src/tool_metadata/read.rs index 6eac6c69..50db9446 100644 --- a/src/reloaded-code-core/src/tool_metadata/read.rs +++ b/src/reloaded-code-core/src/tool_metadata/read.rs @@ -2,33 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "read"; - -/// Default 1-based line offset. -pub const DEFAULT_OFFSET: usize = 1; - -/// Default maximum lines to return. -pub const DEFAULT_LIMIT: usize = 2000; - -/// Maximum characters per output line before truncation. -pub const MAX_LINE_LENGTH: usize = 2000; - -/// Display hint for the line-number prefix in prompts. -pub const LINE_PREFIX_DISPLAY: &str = "{n}: "; - -/// Serde-friendly default offset helper. -#[must_use] -pub const fn default_offset() -> usize { - DEFAULT_OFFSET -} - -/// Serde-friendly default line limit helper. -#[must_use] -pub const fn default_limit() -> usize { - DEFAULT_LIMIT -} - /// Tool descriptions. pub mod description { /// Absolute-path variant. @@ -51,7 +24,6 @@ pub mod description { } } } - /// Parameter metadata. pub mod param { use super::{ParamMetadata, DEFAULT_LIMIT}; @@ -79,3 +51,26 @@ pub mod param { false, ); } + +/// Default maximum lines to return. +pub const DEFAULT_LIMIT: usize = 2000; +/// Default 1-based line offset. +pub const DEFAULT_OFFSET: usize = 1; +/// Display hint for the line-number prefix in prompts. +pub const LINE_PREFIX_DISPLAY: &str = "{n}: "; +/// Maximum characters per output line before truncation. +pub const MAX_LINE_LENGTH: usize = 2000; +/// Canonical tool name. +pub const NAME: &str = "read"; + +/// Serde-friendly default line limit helper. +#[must_use] +pub const fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Serde-friendly default offset helper. +#[must_use] +pub const fn default_offset() -> usize { + DEFAULT_OFFSET +} diff --git a/src/reloaded-code-core/src/tool_metadata/task.rs b/src/reloaded-code-core/src/tool_metadata/task.rs index 2e189626..ad69d665 100644 --- a/src/reloaded-code-core/src/tool_metadata/task.rs +++ b/src/reloaded-code-core/src/tool_metadata/task.rs @@ -2,12 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "task"; - -/// Static description prefix before rendering available targets. -pub const DESCRIPTION_PREFIX: &str = "Delegate work to one of the listed subagents."; - /// Parameter metadata. pub mod param { use super::ParamMetadata; @@ -28,3 +22,8 @@ pub mod param { pub const COMMAND: ParamMetadata = ParamMetadata::new("command", "Source command or slash-command context.", false); } + +/// Static description prefix before rendering available targets. +pub const DESCRIPTION_PREFIX: &str = "Delegate work to one of the listed subagents."; +/// Canonical tool name. +pub const NAME: &str = "task"; diff --git a/src/reloaded-code-core/src/tool_metadata/todo_read.rs b/src/reloaded-code-core/src/tool_metadata/todo_read.rs index e318f236..32841d89 100644 --- a/src/reloaded-code-core/src/tool_metadata/todo_read.rs +++ b/src/reloaded-code-core/src/tool_metadata/todo_read.rs @@ -1,7 +1,6 @@ //! Provider-facing metadata for the `todoread` tool. -/// Canonical tool name. -pub const NAME: &str = "todoread"; - /// Tool description. pub const DESCRIPTION: &str = "Read the current todo list as text."; +/// Canonical tool name. +pub const NAME: &str = "todoread"; diff --git a/src/reloaded-code-core/src/tool_metadata/todo_write.rs b/src/reloaded-code-core/src/tool_metadata/todo_write.rs index 84544109..f9634597 100644 --- a/src/reloaded-code-core/src/tool_metadata/todo_write.rs +++ b/src/reloaded-code-core/src/tool_metadata/todo_write.rs @@ -2,12 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "todowrite"; - -/// Tool description. -pub const DESCRIPTION: &str = "Replace the full todo list."; - /// Parameter metadata. pub mod param { use super::ParamMetadata; @@ -29,3 +23,8 @@ pub mod param { /// Todo item `priority` field metadata. pub const PRIORITY: ParamMetadata = ParamMetadata::new("priority", "Task priority.", true); } + +/// Tool description. +pub const DESCRIPTION: &str = "Replace the full todo list."; +/// Canonical tool name. +pub const NAME: &str = "todowrite"; diff --git a/src/reloaded-code-core/src/tool_metadata/webfetch.rs b/src/reloaded-code-core/src/tool_metadata/webfetch.rs index b1af0f22..58256842 100644 --- a/src/reloaded-code-core/src/tool_metadata/webfetch.rs +++ b/src/reloaded-code-core/src/tool_metadata/webfetch.rs @@ -2,22 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "webfetch"; - -/// Default timeout in milliseconds. -pub const DEFAULT_TIMEOUT_MS: u32 = 30_000; - -/// Maximum timeout in milliseconds. -pub const MAX_TIMEOUT_MS: u32 = 600_000; - -/// Maximum response size in bytes (5 MiB). -pub const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; - -/// Tool description. -pub const DESCRIPTION: &str = - "Fetch one URL. HTML is converted to Markdown and JSON is pretty-printed."; - /// Parameter metadata. pub mod param { use super::{ParamMetadata, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS}; @@ -37,3 +21,15 @@ pub mod param { false, ); } + +/// Default timeout in milliseconds. +pub const DEFAULT_TIMEOUT_MS: u32 = 30_000; +/// Tool description. +pub const DESCRIPTION: &str = + "Fetch one URL. HTML is converted to Markdown and JSON is pretty-printed."; +/// Maximum response size in bytes (5 MiB). +pub const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; +/// Maximum timeout in milliseconds. +pub const MAX_TIMEOUT_MS: u32 = 600_000; +/// Canonical tool name. +pub const NAME: &str = "webfetch"; diff --git a/src/reloaded-code-core/src/tool_metadata/write.rs b/src/reloaded-code-core/src/tool_metadata/write.rs index a773bc9f..a2c9d2a0 100644 --- a/src/reloaded-code-core/src/tool_metadata/write.rs +++ b/src/reloaded-code-core/src/tool_metadata/write.rs @@ -2,9 +2,6 @@ use super::ParamMetadata; -/// Canonical tool name. -pub const NAME: &str = "write"; - /// Tool descriptions. pub mod description { /// Absolute-path variant. @@ -15,7 +12,6 @@ pub mod description { pub const ALLOWED: &str = "Write a file in allowed directories. Creates parent directories and overwrites existing files."; } - /// Parameter metadata. pub mod param { use super::ParamMetadata; @@ -35,3 +31,6 @@ pub mod param { pub const CONTENT: ParamMetadata = ParamMetadata::new("content", "Full file contents to write.", true); } + +/// Canonical tool name. +pub const NAME: &str = "write"; diff --git a/src/reloaded-code-core/src/tools/bash/mod.rs b/src/reloaded-code-core/src/tools/bash/mod.rs index db9f8e33..94c8e6b5 100644 --- a/src/reloaded-code-core/src/tools/bash/mod.rs +++ b/src/reloaded-code-core/src/tools/bash/mod.rs @@ -35,26 +35,46 @@ use crate::error::{ToolError, ToolResult}; use crate::permissions::Ruleset; use crate::ToolOutput; +#[cfg(all(feature = "blocking", not(feature = "tokio")))] +pub use blocking_impl::{execute_command, execute_command_with_mode}; use core::fmt::Write; +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +pub use reloaded_code_bubblewrap::profile as linux_bwrap_profile; +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +use reloaded_code_bubblewrap::profile::Profile; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::borrow::Cow; use std::path::Path; use std::time::Duration; #[cfg(feature = "tokio")] -mod tokio_impl; -#[cfg(feature = "tokio")] pub use tokio_impl::{execute_command, execute_command_with_mode}; #[cfg(all(feature = "blocking", not(feature = "tokio")))] mod blocking_impl; -#[cfg(all(feature = "blocking", not(feature = "tokio")))] -pub use blocking_impl::{execute_command, execute_command_with_mode}; +#[cfg(feature = "tokio")] +mod tokio_impl; +#[cfg(all(test, feature = "linux-bubblewrap", target_os = "linux"))] +mod tests { + use super::*; -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -pub use reloaded_code_bubblewrap::profile as linux_bwrap_profile; -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -use reloaded_code_bubblewrap::profile::Profile; + #[test] + fn bwrap_error_mapping_preserves_variants() { + let mapped = map_linux_bwrap_error(reloaded_code_bubblewrap::LinuxBwrapError::Execution( + "bwrap missing".to_string(), + )); + assert!(matches!(mapped, ToolError::Execution(m) if m.contains("bwrap"))); + + let mapped = map_linux_bwrap_error(reloaded_code_bubblewrap::LinuxBwrapError::InvalidPath( + "bad path".to_string(), + )); + assert!(matches!(mapped, ToolError::InvalidPath(m) if m.contains("bad"))); + } +} + +/// Default buffer capacity for stdout/stderr pipe reads. +/// 32KB covers typical command output without reallocations. +const PIPE_BUFFER_CAPACITY: usize = 32 * 1024; /// Execution mode for bash commands. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -65,6 +85,17 @@ pub enum BashExecutionMode { LinuxBwrap(std::sync::Arc), } +/// Result of shell command execution. +#[derive(Debug, Clone, Serialize)] +pub struct BashOutput { + /// Exit code from the command (None if killed by timeout). + pub exit_code: Option, + /// Standard output from the command. + pub stdout: String, + /// Standard error output from the command. + pub stderr: String, +} + /// Serde-friendly bash request owned by the core crate. #[derive(Debug, Clone, Deserialize)] pub struct BashRequest { @@ -76,17 +107,6 @@ pub struct BashRequest { pub timeout_ms: Option, } -impl BashRequest { - /// Parses a raw JSON tool payload into a bash request. - /// - /// # Errors - /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`BashRequest`] (e.g., missing `command` field or invalid field types). - pub fn parse(args: Value) -> ToolResult { - serde_json::from_value(args).map_err(ToolError::from) - } -} - /// Runtime settings applied to bash requests. /// /// When [`BashSettings::permission`] is set, operations may return @@ -105,9 +125,63 @@ pub struct BashSettings<'a> { pub permission: Option<&'a Ruleset>, } -/// Default buffer capacity for stdout/stderr pipe reads. -/// 32KB covers typical command output without reallocations. -const PIPE_BUFFER_CAPACITY: usize = 32 * 1024; +impl BashOutput { + /// Formats the bash output into a [`ToolOutput`] for LLM consumption. + /// + /// Combines stdout, stderr (with `[stderr]` label), and non-zero exit codes + /// into a single formatted string. + pub fn format_output(&self) -> ToolOutput { + // Pre-allocate: stdout + stderr + labels overhead (~34 bytes) + // 34 bytes assumes the exit code is up to 10 digits, i.e. int32 range. + let estimated = self.stdout.len() + self.stderr.len() + 34; + let mut content = String::with_capacity(estimated); + + if !self.stdout.is_empty() { + content.push_str(&self.stdout); + } + + if !self.stderr.is_empty() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str("[stderr]\n"); + content.push_str(&self.stderr); + } + + if let Some(code) = self.exit_code { + if code != 0 { + if !content.is_empty() { + content.push('\n'); + } + // Use write! to avoid format! allocation + let _ = write!(content, "[exit code: {code}]"); + } + } + + ToolOutput::new(content) + } +} + +impl BashRequest { + /// Parses a raw JSON tool payload into a bash request. + /// + /// # Errors + /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized + /// into a [`BashRequest`] (e.g., missing `command` field or invalid field types). + pub fn parse(args: Value) -> ToolResult { + serde_json::from_value(args).map_err(ToolError::from) + } +} + +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +#[inline] +fn map_linux_bwrap_error(error: reloaded_code_bubblewrap::LinuxBwrapError) -> ToolError { + use reloaded_code_bubblewrap::LinuxBwrapError; + match error { + LinuxBwrapError::InvalidPath(message) => ToolError::InvalidPath(message), + LinuxBwrapError::Execution(message) => ToolError::Execution(message), + } +} #[inline] fn string_from_utf8_or_lossy(bytes: Vec) -> String { @@ -120,6 +194,17 @@ fn string_from_utf8_or_lossy(bytes: Vec) -> String { } } +#[inline] +fn timeout_error_with_kill_failure(message: String, kill_error: Option) -> ToolError { + match kill_error { + Some(kill_error) => ToolError::TimeoutWithKillFailure { + message, + kill_error, + }, + None => ToolError::Timeout(message), + } +} + #[inline] fn timeout_message_with_buffered_output( timeout: Duration, @@ -148,17 +233,6 @@ fn timeout_message_with_buffered_output( message } -#[inline] -fn timeout_error_with_kill_failure(message: String, kill_error: Option) -> ToolError { - match kill_error { - Some(kill_error) => ToolError::TimeoutWithKillFailure { - message, - kill_error, - }, - None => ToolError::Timeout(message), - } -} - #[inline] fn validate_workdir(workdir: Option<&Path>) -> ToolResult<()> { if let Some(dir) = workdir { @@ -179,79 +253,3 @@ fn validate_workdir(workdir: Option<&Path>) -> ToolResult<()> { } Ok(()) } - -/// Result of shell command execution. -#[derive(Debug, Clone, Serialize)] -pub struct BashOutput { - /// Exit code from the command (None if killed by timeout). - pub exit_code: Option, - /// Standard output from the command. - pub stdout: String, - /// Standard error output from the command. - pub stderr: String, -} - -impl BashOutput { - /// Formats the bash output into a [`ToolOutput`] for LLM consumption. - /// - /// Combines stdout, stderr (with `[stderr]` label), and non-zero exit codes - /// into a single formatted string. - pub fn format_output(&self) -> ToolOutput { - // Pre-allocate: stdout + stderr + labels overhead (~34 bytes) - // 34 bytes assumes the exit code is up to 10 digits, i.e. int32 range. - let estimated = self.stdout.len() + self.stderr.len() + 34; - let mut content = String::with_capacity(estimated); - - if !self.stdout.is_empty() { - content.push_str(&self.stdout); - } - - if !self.stderr.is_empty() { - if !content.is_empty() { - content.push('\n'); - } - content.push_str("[stderr]\n"); - content.push_str(&self.stderr); - } - - if let Some(code) = self.exit_code { - if code != 0 { - if !content.is_empty() { - content.push('\n'); - } - // Use write! to avoid format! allocation - let _ = write!(content, "[exit code: {code}]"); - } - } - - ToolOutput::new(content) - } -} - -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -#[inline] -fn map_linux_bwrap_error(error: reloaded_code_bubblewrap::LinuxBwrapError) -> ToolError { - use reloaded_code_bubblewrap::LinuxBwrapError; - match error { - LinuxBwrapError::InvalidPath(message) => ToolError::InvalidPath(message), - LinuxBwrapError::Execution(message) => ToolError::Execution(message), - } -} - -#[cfg(all(test, feature = "linux-bubblewrap", target_os = "linux"))] -mod tests { - use super::*; - - #[test] - fn bwrap_error_mapping_preserves_variants() { - let mapped = map_linux_bwrap_error(reloaded_code_bubblewrap::LinuxBwrapError::Execution( - "bwrap missing".to_string(), - )); - assert!(matches!(mapped, ToolError::Execution(m) if m.contains("bwrap"))); - - let mapped = map_linux_bwrap_error(reloaded_code_bubblewrap::LinuxBwrapError::InvalidPath( - "bad path".to_string(), - )); - assert!(matches!(mapped, ToolError::InvalidPath(m) if m.contains("bad"))); - } -} diff --git a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs index 03d597a8..fda67f29 100644 --- a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs +++ b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs @@ -25,66 +25,12 @@ const PIPE_DRAIN_GRACE_PERIOD: Duration = Duration::from_millis(100); /// Read chunk size for async pipe draining. const PIPE_DRAIN_READ_CHUNK: usize = 8 * 1024; -type SharedPipeBuffer = Arc>>; - struct PipeDrainTask { handle: JoinHandle<()>, buffer: SharedPipeBuffer, } -#[inline] -fn spawn_pipe_drain_task(mut pipe: R) -> PipeDrainTask -where - R: AsyncRead + Unpin + Send + 'static, -{ - let buffer: SharedPipeBuffer = Arc::new(Mutex::new(Vec::with_capacity(PIPE_BUFFER_CAPACITY))); - let task_buffer = Arc::clone(&buffer); - - let handle = tokio::spawn(async move { - let mut chunk = [0_u8; PIPE_DRAIN_READ_CHUNK]; - loop { - match pipe.read(&mut chunk).await { - Ok(0) => break, - Ok(read) => task_buffer.lock().extend_from_slice(&chunk[..read]), - Err(_) => break, - } - } - }); - - PipeDrainTask { handle, buffer } -} - -#[inline] -fn take_pipe_buffer(buffer: SharedPipeBuffer) -> Vec { - match Arc::try_unwrap(buffer) { - Ok(mutex) => mutex.into_inner(), - Err(shared) => core::mem::take(&mut *shared.lock()), - } -} - -#[inline] -async fn await_pipe_drain_task(task: PipeDrainTask) -> Vec { - let PipeDrainTask { handle, buffer } = task; - let _ = handle.await; - take_pipe_buffer(buffer) -} - -#[inline] -async fn await_pipe_drain_task_with_grace(task: PipeDrainTask, grace: Duration) -> Vec { - let PipeDrainTask { mut handle, buffer } = task; - - tokio::select! { - _ = &mut handle => {}, - _ = tokio::time::sleep(grace) => { - // Preserve strict timeout semantics while retaining buffered bytes. - // Buffer state is shared outside the task so abort cannot discard it. - handle.abort(); - let _ = handle.await; - } - } - - take_pipe_buffer(buffer) -} +type SharedPipeBuffer = Arc>>; /// Executes a shell command with optional working directory and timeout. /// @@ -236,6 +182,30 @@ pub(in crate::tools::bash) async fn run_wrapped_command( } } +#[inline] +async fn await_pipe_drain_task(task: PipeDrainTask) -> Vec { + let PipeDrainTask { handle, buffer } = task; + let _ = handle.await; + take_pipe_buffer(buffer) +} + +#[inline] +async fn await_pipe_drain_task_with_grace(task: PipeDrainTask, grace: Duration) -> Vec { + let PipeDrainTask { mut handle, buffer } = task; + + tokio::select! { + _ = &mut handle => {}, + _ = tokio::time::sleep(grace) => { + // Preserve strict timeout semantics while retaining buffered bytes. + // Buffer state is shared outside the task so abort cannot discard it. + handle.abort(); + let _ = handle.await; + } + } + + take_pipe_buffer(buffer) +} + fn build_host_wrap(command: &str, workdir: Option<&Path>) -> ToolResult { validate_workdir(workdir)?; @@ -269,6 +239,36 @@ fn build_host_wrap(command: &str, workdir: Option<&Path>) -> ToolResult(mut pipe: R) -> PipeDrainTask +where + R: AsyncRead + Unpin + Send + 'static, +{ + let buffer: SharedPipeBuffer = Arc::new(Mutex::new(Vec::with_capacity(PIPE_BUFFER_CAPACITY))); + let task_buffer = Arc::clone(&buffer); + + let handle = tokio::spawn(async move { + let mut chunk = [0_u8; PIPE_DRAIN_READ_CHUNK]; + loop { + match pipe.read(&mut chunk).await { + Ok(0) => break, + Ok(read) => task_buffer.lock().extend_from_slice(&chunk[..read]), + Err(_) => break, + } + } + }); + + PipeDrainTask { handle, buffer } +} + +#[inline] +fn take_pipe_buffer(buffer: SharedPipeBuffer) -> Vec { + match Arc::try_unwrap(buffer) { + Ok(mutex) => mutex.into_inner(), + Err(shared) => core::mem::take(&mut *shared.lock()), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/tools/edit.rs b/src/reloaded-code-core/src/tools/edit.rs index 472b8134..6c6e3c72 100644 --- a/src/reloaded-code-core/src/tools/edit.rs +++ b/src/reloaded-code-core/src/tools/edit.rs @@ -43,6 +43,12 @@ pub struct EditRequest { pub replace_all: bool, } +/// Runtime settings for edit requests. +/// +/// Reserved for future use. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EditSettings {} + impl EditRequest { /// Parses a raw JSON tool payload into an edit request. /// @@ -55,6 +61,14 @@ impl EditRequest { } } +impl EditSettings { + /// Creates default edit settings. + #[must_use] + pub fn new() -> Self { + Self {} + } +} + impl From for EditError { fn from(e: std::io::Error) -> Self { EditError::Tool(ToolError::from(e)) @@ -82,20 +96,6 @@ impl From for ToolError { } } -/// Runtime settings for edit requests. -/// -/// Reserved for future use. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EditSettings {} - -impl EditSettings { - /// Creates default edit settings. - #[must_use] - pub fn new() -> Self { - Self {} - } -} - /// Performs exact string replacement in a file. /// /// Returns success message with replacement count. diff --git a/src/reloaded-code-core/src/tools/glob.rs b/src/reloaded-code-core/src/tools/glob.rs index 2335f1f0..4d2e0927 100644 --- a/src/reloaded-code-core/src/tools/glob.rs +++ b/src/reloaded-code-core/src/tools/glob.rs @@ -10,6 +10,22 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::SystemTime; +/// Output from glob file matching. +#[derive(Debug, Serialize)] +pub struct GlobOutput { + /// Matched file paths relative to search directory, sorted by mtime (newest first). + pub files: Vec, + /// Whether results were truncated due to limit. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, + /// Whether one or more paths could not be traversed or processed. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub partial: bool, + /// Per-path traversal errors encountered while collecting matches. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, +} + /// Serde-friendly glob request owned by the core crate. #[derive(Debug, Deserialize)] pub struct GlobRequest { @@ -19,18 +35,6 @@ pub struct GlobRequest { pub path: String, } -impl GlobRequest { - /// Parses a raw JSON tool payload into a glob request. - /// - /// # Errors - /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`GlobRequest`] (e.g., missing required `pattern` or `path` fields, - /// or invalid field types). - pub fn parse(args: Value) -> ToolResult { - serde_json::from_value(args).map_err(ToolError::from) - } -} - /// Runtime settings applied to glob requests. /// /// The `limit` field caps the number of file paths returned. @@ -40,9 +44,15 @@ pub struct GlobSettings { limit: usize, } -impl Default for GlobSettings { - fn default() -> Self { - Self::new() +impl GlobRequest { + /// Parses a raw JSON tool payload into a glob request. + /// + /// # Errors + /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized + /// into a [`GlobRequest`] (e.g., missing required `pattern` or `path` fields, + /// or invalid field types). + pub fn parse(args: Value) -> ToolResult { + serde_json::from_value(args).map_err(ToolError::from) } } @@ -83,20 +93,10 @@ impl GlobSettings { } } -/// Output from glob file matching. -#[derive(Debug, Serialize)] -pub struct GlobOutput { - /// Matched file paths relative to search directory, sorted by mtime (newest first). - pub files: Vec, - /// Whether results were truncated due to limit. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub truncated: bool, - /// Whether one or more paths could not be traversed or processed. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub partial: bool, - /// Per-path traversal errors encountered while collecting matches. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub errors: Vec, +impl Default for GlobSettings { + fn default() -> Self { + Self::new() + } } /// Finds files matching a glob pattern in the given directory. diff --git a/src/reloaded-code-core/src/tools/grep.rs b/src/reloaded-code-core/src/tools/grep.rs index e9512abf..33d853d4 100644 --- a/src/reloaded-code-core/src/tools/grep.rs +++ b/src/reloaded-code-core/src/tools/grep.rs @@ -16,10 +16,44 @@ use std::time::SystemTime; /// Default maximum line length (in characters) for formatted grep output. pub const DEFAULT_MAX_LINE_LENGTH: usize = 2000; - /// Estimated characters per grep match for buffer pre-allocation. const ESTIMATED_CHARS_PER_MATCH: usize = 128; +struct FileSearchResult { + matches: Vec, + error: Option, +} + +/// Formatting settings for rendered grep output. +/// +/// Controls how matching lines are displayed: truncation length for long lines +/// and whether to prefix each line with a line number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GrepFormattingSettings { + max_line_length: usize, + line_numbers: bool, +} + +/// Output from grep search. +#[derive(Debug, Serialize)] +pub struct GrepOutput { + /// Files with matches, sorted by modification time (newest first). + pub files: Vec, + /// Total match count across all files. + pub match_count: usize, + /// Whether results were truncated due to limit. + pub truncated: bool, + /// Whether one or more files could not be searched. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub partial: bool, + /// Per-file traversal/search errors encountered while collecting matches. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, + /// Effective match limit applied to the search. + #[serde(skip)] + pub effective_limit: usize, +} + /// Serde-friendly grep request owned by the core crate. #[derive(Debug, Deserialize)] pub struct GrepRequest { @@ -31,18 +65,6 @@ pub struct GrepRequest { pub limit: Option, } -impl GrepRequest { - /// Parses a raw JSON tool payload into a grep request. - /// - /// # Errors - /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`GrepRequest`] (e.g., missing required `pattern` or `path` fields, - /// or invalid field types). - pub fn parse(args: Value) -> ToolResult { - serde_json::from_value(args).map_err(ToolError::from) - } -} - /// Runtime settings applied to grep requests. /// /// The `max_limit` field caps the number of matching lines returned, even if @@ -52,63 +74,24 @@ pub struct GrepSettings { max_limit: usize, } -impl Default for GrepSettings { - fn default() -> Self { - Self::new() - } -} - -impl GrepSettings { - /// Creates valid grep search settings with the standard defaults. - #[must_use] - pub fn new() -> Self { - Self { - max_limit: grep_meta::DEFAULT_LIMIT, - } - } - - /// Sets the upper bound on matching lines returned per search. - /// - /// # Errors - /// - Returns an error when `max_limit` is below [`MIN_LIMIT`]. - /// - /// [`MIN_LIMIT`]: crate::util::MIN_LIMIT - pub fn with_max_limit(mut self, max_limit: usize) -> ToolResult { - use crate::util::MIN_LIMIT; - if max_limit < MIN_LIMIT { - return Err(ToolError::validation_for( - "max_limit", - format!("max_limit must be >= {}", MIN_LIMIT), - )); - } - self.max_limit = max_limit; - Ok(self) - } - - /// Returns the upper bound on matching lines returned per search. - /// - /// # Returns - /// - The configured maximum line limit. - #[must_use] - pub const fn max_limit(&self) -> usize { - self.max_limit - } -} - -/// Formatting settings for rendered grep output. -/// -/// Controls how matching lines are displayed: truncation length for long lines -/// and whether to prefix each line with a line number. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GrepFormattingSettings { - max_line_length: usize, - line_numbers: bool, +/// All matches within a single file. +#[derive(Debug, Clone, Serialize)] +pub struct GrepFileMatches { + /// File path. + pub path: String, + /// Matches in this file, in line order. + pub matches: Vec, + #[serde(skip)] + pub(crate) mtime: SystemTime, } -impl Default for GrepFormattingSettings { - fn default() -> Self { - Self::new() - } +/// A single line match within a file. +#[derive(Debug, Clone, Serialize)] +pub struct GrepLineMatch { + /// 1-indexed line number. + pub line_num: u64, + /// Content of the matched line. + pub line_text: String, } impl GrepFormattingSettings { @@ -160,46 +143,6 @@ impl GrepFormattingSettings { } } -/// A single line match within a file. -#[derive(Debug, Clone, Serialize)] -pub struct GrepLineMatch { - /// 1-indexed line number. - pub line_num: u64, - /// Content of the matched line. - pub line_text: String, -} - -/// All matches within a single file. -#[derive(Debug, Clone, Serialize)] -pub struct GrepFileMatches { - /// File path. - pub path: String, - /// Matches in this file, in line order. - pub matches: Vec, - #[serde(skip)] - pub(crate) mtime: SystemTime, -} - -/// Output from grep search. -#[derive(Debug, Serialize)] -pub struct GrepOutput { - /// Files with matches, sorted by modification time (newest first). - pub files: Vec, - /// Total match count across all files. - pub match_count: usize, - /// Whether results were truncated due to limit. - pub truncated: bool, - /// Whether one or more files could not be searched. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub partial: bool, - /// Per-file traversal/search errors encountered while collecting matches. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub errors: Vec, - /// Effective match limit applied to the search. - #[serde(skip)] - pub effective_limit: usize, -} - impl GrepOutput { /// Formats grep results as human-readable text. /// @@ -279,6 +222,67 @@ impl GrepOutput { } } +impl GrepRequest { + /// Parses a raw JSON tool payload into a grep request. + /// + /// # Errors + /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized + /// into a [`GrepRequest`] (e.g., missing required `pattern` or `path` fields, + /// or invalid field types). + pub fn parse(args: Value) -> ToolResult { + serde_json::from_value(args).map_err(ToolError::from) + } +} + +impl GrepSettings { + /// Creates valid grep search settings with the standard defaults. + #[must_use] + pub fn new() -> Self { + Self { + max_limit: grep_meta::DEFAULT_LIMIT, + } + } + + /// Sets the upper bound on matching lines returned per search. + /// + /// # Errors + /// - Returns an error when `max_limit` is below [`MIN_LIMIT`]. + /// + /// [`MIN_LIMIT`]: crate::util::MIN_LIMIT + pub fn with_max_limit(mut self, max_limit: usize) -> ToolResult { + use crate::util::MIN_LIMIT; + if max_limit < MIN_LIMIT { + return Err(ToolError::validation_for( + "max_limit", + format!("max_limit must be >= {}", MIN_LIMIT), + )); + } + self.max_limit = max_limit; + Ok(self) + } + + /// Returns the upper bound on matching lines returned per search. + /// + /// # Returns + /// - The configured maximum line limit. + #[must_use] + pub const fn max_limit(&self) -> usize { + self.max_limit + } +} + +impl Default for GrepFormattingSettings { + fn default() -> Self { + Self::new() + } +} + +impl Default for GrepSettings { + fn default() -> Self { + Self::new() + } +} + /// Searches for content matching a regex pattern. /// /// Results are sorted by modification time (newest first). @@ -425,11 +429,6 @@ pub fn grep_search( }) } -struct FileSearchResult { - matches: Vec, - error: Option, -} - #[inline] fn collect_file_matches( matcher: &RegexMatcher, diff --git a/src/reloaded-code-core/src/tools/mod.rs b/src/reloaded-code-core/src/tools/mod.rs index ba3117a0..beebedbf 100644 --- a/src/reloaded-code-core/src/tools/mod.rs +++ b/src/reloaded-code-core/src/tools/mod.rs @@ -5,15 +5,6 @@ //! - Web fetching (fetch_url) - requires `async` or `blocking` feature // Always available (sync or async based on runtime feature) -pub mod bash; -pub mod edit; -pub mod glob; -pub mod grep; -pub mod read; -pub mod task; -pub mod todo; -pub mod write; - #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] pub use bash::linux_bwrap_profile; pub use bash::{ @@ -32,13 +23,20 @@ pub use todo::{ read_todos, write_todos, Todo, TodoPriority, TodoReadRequest, TodoState, TodoStatus, TodoWriteRequest, }; +#[cfg(any(feature = "tokio", feature = "blocking"))] +pub use webfetch::{ + fetch_url, format_json, html_to_markdown, WebFetchOutput, WebFetchRequest, WebFetchSettings, +}; pub use write::{write_file, WriteRequest, WriteSettings}; +pub mod bash; +pub mod edit; +pub mod glob; +pub mod grep; +pub mod read; +pub mod task; +pub mod todo; +pub mod write; // Webfetch available in both tokio and blocking modes #[cfg(any(feature = "tokio", feature = "blocking"))] pub mod webfetch; - -#[cfg(any(feature = "tokio", feature = "blocking"))] -pub use webfetch::{ - fetch_url, format_json, html_to_markdown, WebFetchOutput, WebFetchRequest, WebFetchSettings, -}; diff --git a/src/reloaded-code-core/src/tools/read.rs b/src/reloaded-code-core/src/tools/read.rs index 2bc5cfb6..5416adb2 100644 --- a/src/reloaded-code-core/src/tools/read.rs +++ b/src/reloaded-code-core/src/tools/read.rs @@ -12,6 +12,12 @@ use memchr::{memchr, memchr_iter}; use serde::Deserialize; use serde_json::Value; +#[cfg(feature = "blocking")] +type BufFile = std::io::BufReader; + +#[cfg(feature = "tokio")] +type BufFile = tokio::io::BufReader; + /// Serde-friendly read request owned by the core crate. #[derive(Debug, Deserialize)] pub struct ReadRequest { @@ -22,18 +28,6 @@ pub struct ReadRequest { pub limit: Option, } -impl ReadRequest { - /// Parses a raw JSON tool payload into a read request. - /// - /// # Errors - /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`ReadRequest`] (e.g., missing required `file_path` field or - /// invalid field types). - pub fn parse(args: Value) -> ToolResult { - serde_json::from_value(args).map_err(ToolError::from) - } -} - /// Runtime settings applied to read requests. /// /// Controls how many lines a read returns using a two-limit model: @@ -49,9 +43,15 @@ pub struct ReadSettings { line_numbers: bool, } -impl Default for ReadSettings { - fn default() -> Self { - Self::new() +impl ReadRequest { + /// Parses a raw JSON tool payload into a read request. + /// + /// # Errors + /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized + /// into a [`ReadRequest`] (e.g., missing required `file_path` field or + /// invalid field types). + pub fn parse(args: Value) -> ToolResult { + serde_json::from_value(args).map_err(ToolError::from) } } @@ -182,10 +182,11 @@ impl ReadSettings { } } -#[cfg(feature = "blocking")] -type BufFile = std::io::BufReader; -#[cfg(feature = "tokio")] -type BufFile = tokio::io::BufReader; +impl Default for ReadSettings { + fn default() -> Self { + Self::new() + } +} /// Reads a range of lines from a file using buffered, streaming I/O with /// SIMD-accelerated newline scanning. @@ -470,6 +471,40 @@ fn emit_line( } } +fn ensure_max_line_length(max_line_length: usize) -> ToolResult<()> { + use crate::util::MIN_LINE_LENGTH; + if max_line_length < MIN_LINE_LENGTH { + return Err(ToolError::validation_for( + "max_line_length", + format!("max_line_length must be >= {}", MIN_LINE_LENGTH), + )); + } + Ok(()) +} + +fn ensure_read_limits(default_limit: usize, max_limit: usize) -> ToolResult<()> { + use crate::util::MIN_LIMIT; + if default_limit < MIN_LIMIT { + return Err(ToolError::validation_for( + "default_limit", + format!("default_limit must be >= {}", MIN_LIMIT), + )); + } + if max_limit < MIN_LIMIT { + return Err(ToolError::validation_for( + "max_limit", + format!("max_limit must be >= {}", MIN_LIMIT), + )); + } + if default_limit > max_limit { + return Err(ToolError::validation_for( + "default_limit", + format!("default_limit ({default_limit}) must be <= max_limit ({max_limit})"), + )); + } + Ok(()) +} + /// Processes a single line, appending it to output with optional line numbers. /// /// Const-generic over `LINE_NUMBERS` so the compiler can eliminate the @@ -509,16 +544,6 @@ fn process_line( *lines_output += 1; } -/// Strips trailing CR from a line (for CRLF handling). -#[inline] -fn strip_cr(line: &[u8]) -> &[u8] { - if line.last() == Some(&b'\r') { - &line[..line.len() - 1] - } else { - line - } -} - #[inline] fn append_line_content(output: &mut String, content: &str, max_line_length: usize) { let (display_content, was_truncated) = truncate_line_with_ellipsis(content, max_line_length); @@ -528,38 +553,14 @@ fn append_line_content(output: &mut String, content: &str, max_line_length: usiz } } -fn ensure_read_limits(default_limit: usize, max_limit: usize) -> ToolResult<()> { - use crate::util::MIN_LIMIT; - if default_limit < MIN_LIMIT { - return Err(ToolError::validation_for( - "default_limit", - format!("default_limit must be >= {}", MIN_LIMIT), - )); - } - if max_limit < MIN_LIMIT { - return Err(ToolError::validation_for( - "max_limit", - format!("max_limit must be >= {}", MIN_LIMIT), - )); - } - if default_limit > max_limit { - return Err(ToolError::validation_for( - "default_limit", - format!("default_limit ({default_limit}) must be <= max_limit ({max_limit})"), - )); - } - Ok(()) -} - -fn ensure_max_line_length(max_line_length: usize) -> ToolResult<()> { - use crate::util::MIN_LINE_LENGTH; - if max_line_length < MIN_LINE_LENGTH { - return Err(ToolError::validation_for( - "max_line_length", - format!("max_line_length must be >= {}", MIN_LINE_LENGTH), - )); +/// Strips trailing CR from a line (for CRLF handling). +#[inline] +fn strip_cr(line: &[u8]) -> &[u8] { + if line.last() == Some(&b'\r') { + &line[..line.len() - 1] + } else { + line } - Ok(()) } #[cfg(test)] diff --git a/src/reloaded-code-core/src/tools/task.rs b/src/reloaded-code-core/src/tools/task.rs index 41e44dd7..4859f4f5 100644 --- a/src/reloaded-code-core/src/tools/task.rs +++ b/src/reloaded-code-core/src/tools/task.rs @@ -9,6 +9,28 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Input for task execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskInput { + /// Short description (3-5 words) of the task. + pub description: String, + /// The prompt/task for the agent to perform. + pub prompt: String, + /// The subagent type/name to invoke. + pub subagent_type: String, + /// Optional command that triggered this task (for context). + pub command: Option, +} + +/// Output from task execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskOutput { + /// The text summary/response from the agent. + pub summary: String, + /// Optional metadata from the execution. + pub metadata: Option, +} + /// Shared runtime settings for Task delegation. /// /// # Delegation depth @@ -18,7 +40,7 @@ use serde_json::Value; /// delegated hops are allowed before Task must stop delegating further. /// /// | `current_depth` | Allowed? | -/// |-----------------|----------| +/// | --------------- | -------- | /// | `0` | yes | /// | `1` | yes | /// | `2` | yes | @@ -32,13 +54,22 @@ pub struct TaskSettings { max_depth: u8, } -impl Default for TaskSettings { +impl TaskOutput { + /// Creates a new task output with just a summary. #[inline] - fn default() -> Self { + pub fn new(summary: impl Into) -> Self { Self { - max_depth: Self::DEFAULT_MAX_DEPTH, + summary: summary.into(), + metadata: None, } } + + /// Sets metadata. + #[inline] + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } } impl TaskSettings { @@ -66,44 +97,13 @@ impl TaskSettings { } } -/// Input for task execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskInput { - /// Short description (3-5 words) of the task. - pub description: String, - /// The prompt/task for the agent to perform. - pub prompt: String, - /// The subagent type/name to invoke. - pub subagent_type: String, - /// Optional command that triggered this task (for context). - pub command: Option, -} - -/// Output from task execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskOutput { - /// The text summary/response from the agent. - pub summary: String, - /// Optional metadata from the execution. - pub metadata: Option, -} - -impl TaskOutput { - /// Creates a new task output with just a summary. +impl Default for TaskSettings { #[inline] - pub fn new(summary: impl Into) -> Self { + fn default() -> Self { Self { - summary: summary.into(), - metadata: None, + max_depth: Self::DEFAULT_MAX_DEPTH, } } - - /// Sets metadata. - #[inline] - pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { - self.metadata = Some(metadata); - self - } } #[cfg(test)] diff --git a/src/reloaded-code-core/src/tools/todo.rs b/src/reloaded-code-core/src/tools/todo.rs index 07e4ca4d..2b871fc3 100644 --- a/src/reloaded-code-core/src/tools/todo.rs +++ b/src/reloaded-code-core/src/tools/todo.rs @@ -8,43 +8,21 @@ use serde_json::Value; use std::fmt::Write; use std::sync::Arc; -/// Task status. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum TodoStatus { - /// Not yet started. - Pending, - /// Currently being worked on. - InProgress, - /// Successfully finished. - Completed, - /// Abandoned or no longer relevant. - Cancelled, -} +/// Serde-friendly todo-read request owned by the core crate. +#[derive(Debug, Clone, Deserialize)] +pub struct TodoReadRequest {} -impl TodoStatus { - /// Returns the status indicator icon. - #[inline] - pub const fn icon(self) -> &'static str { - match self { - Self::Pending => "[ ]", - Self::InProgress => "[>]", - Self::Completed => "[x]", - Self::Cancelled => "[-]", - } - } +/// Thread-safe shared state for todo list. +#[derive(Debug, Clone, Default)] +pub struct TodoState { + todos: Arc>>, } -/// Task priority level. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum TodoPriority { - /// Urgent, should be addressed first. - High, - /// Normal priority. - Medium, - /// Can be deferred. - Low, +/// Serde-friendly todo-write request owned by the core crate. +#[derive(Debug, Clone, Deserialize)] +pub struct TodoWriteRequest { + /// The complete list of todos to set. + pub todos: Vec, } /// A single task item. @@ -60,54 +38,100 @@ pub struct Todo { pub priority: TodoPriority, } -/// Thread-safe shared state for todo list. -#[derive(Debug, Clone, Default)] -pub struct TodoState { - todos: Arc>>, +/// Task priority level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TodoPriority { + /// Urgent, should be addressed first. + High, + /// Normal priority. + Medium, + /// Can be deferred. + Low, } -/// Serde-friendly todo-write request owned by the core crate. -#[derive(Debug, Clone, Deserialize)] -pub struct TodoWriteRequest { - /// The complete list of todos to set. - pub todos: Vec, +/// Task status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TodoStatus { + /// Not yet started. + Pending, + /// Currently being worked on. + InProgress, + /// Successfully finished. + Completed, + /// Abandoned or no longer relevant. + Cancelled, } -impl TodoWriteRequest { - /// Parses a raw JSON tool payload into a todo-write request. +impl TodoReadRequest { + /// Parses a raw JSON tool payload into a todo-read request. /// /// # Errors /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`TodoWriteRequest`] (e.g., missing `todos` field or invalid todo - /// structure). + /// into a [`TodoReadRequest`]. pub fn parse(args: Value) -> ToolResult { serde_json::from_value(args).map_err(ToolError::from) } } -/// Serde-friendly todo-read request owned by the core crate. -#[derive(Debug, Clone, Deserialize)] -pub struct TodoReadRequest {} +impl TodoState { + /// Creates a new empty todo state. + #[inline] + pub fn new() -> Self { + Self::default() + } +} -impl TodoReadRequest { - /// Parses a raw JSON tool payload into a todo-read request. +impl TodoWriteRequest { + /// Parses a raw JSON tool payload into a todo-write request. /// /// # Errors /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`TodoReadRequest`]. + /// into a [`TodoWriteRequest`] (e.g., missing `todos` field or invalid todo + /// structure). pub fn parse(args: Value) -> ToolResult { serde_json::from_value(args).map_err(ToolError::from) } } -impl TodoState { - /// Creates a new empty todo state. +impl TodoStatus { + /// Returns the status indicator icon. #[inline] - pub fn new() -> Self { - Self::default() + pub const fn icon(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::InProgress => "[>]", + Self::Completed => "[x]", + Self::Cancelled => "[-]", + } } } +/// Reads and formats the current todo list. +pub fn read_todos(state: &TodoState, _request: TodoReadRequest) -> String { + let todos = state.todos.read(); + + if todos.is_empty() { + return "No tasks.".to_string(); + } + + let mut output = format!("Tasks ({} total):\n", todos.len()); + for todo in todos.iter() { + let _ = writeln!( + output, + "{} ({:?}) {}: {}", + todo.status.icon(), + todo.priority, + todo.id, + todo.content + ); + } + + output.truncate(output.trim_end().len()); + output +} + /// Writes/replaces the todo list with new items. /// /// Validates that all todos have non-empty id and content. @@ -136,30 +160,6 @@ pub fn write_todos(state: &TodoState, request: TodoWriteRequest) -> ToolResult String { - let todos = state.todos.read(); - - if todos.is_empty() { - return "No tasks.".to_string(); - } - - let mut output = format!("Tasks ({} total):\n", todos.len()); - for todo in todos.iter() { - let _ = writeln!( - output, - "{} ({:?}) {}: {}", - todo.status.icon(), - todo.priority, - todo.id, - todo.content - ); - } - - output.truncate(output.trim_end().len()); - output -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/tools/webfetch/mod.rs b/src/reloaded-code-core/src/tools/webfetch/mod.rs index 6b1d399e..ce802b9e 100644 --- a/src/reloaded-code-core/src/tools/webfetch/mod.rs +++ b/src/reloaded-code-core/src/tools/webfetch/mod.rs @@ -3,11 +3,31 @@ use crate::error::{ToolError, ToolResult}; use crate::tool_metadata::webfetch as webfetch_meta; use crate::util::MIN_TIMEOUT_MS; +#[cfg(all(feature = "blocking", not(feature = "tokio")))] +pub use blocking_impl::fetch_url; use html_to_markdown_rs::{ convert, ConversionOptions, ConversionResult, PreprocessingOptions, PreprocessingPreset, }; use serde::Deserialize; use serde_json::Value; +#[cfg(feature = "tokio")] +pub use tokio_impl::fetch_url; + +#[cfg(all(feature = "blocking", not(feature = "tokio")))] +mod blocking_impl; +#[cfg(feature = "tokio")] +mod tokio_impl; + +/// Result from URL fetch operation. +#[derive(Debug, Clone)] +pub struct WebFetchOutput { + /// The processed content (HTML converted to markdown, JSON prettified). + pub content: String, + /// The Content-Type header value. + pub content_type: String, + /// Original byte length before processing. + pub byte_length: usize, +} /// Serde-friendly webfetch request owned by the core crate. #[derive(Debug, Clone, Deserialize)] @@ -19,17 +39,6 @@ pub struct WebFetchRequest { pub timeout_ms: Option, } -impl WebFetchRequest { - /// Parses a raw JSON tool payload into a webfetch request. - /// - /// # Errors - /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized - /// into a [`WebFetchRequest`] (e.g., missing `url` field or invalid field types). - pub fn parse(args: Value) -> ToolResult { - serde_json::from_value(args).map_err(ToolError::from) - } -} - /// Runtime settings applied to webfetch requests. /// /// Controls request duration using a two-timeout model: @@ -44,9 +53,14 @@ pub struct WebFetchSettings { max_response_size: usize, } -impl Default for WebFetchSettings { - fn default() -> Self { - Self::new() +impl WebFetchRequest { + /// Parses a raw JSON tool payload into a webfetch request. + /// + /// # Errors + /// - Returns [`ToolError::Json`] when the JSON payload cannot be deserialized + /// into a [`WebFetchRequest`] (e.g., missing `url` field or invalid field types). + pub fn parse(args: Value) -> ToolResult { + serde_json::from_value(args).map_err(ToolError::from) } } @@ -152,50 +166,43 @@ impl WebFetchSettings { } } -fn ensure_timeouts(default_timeout_ms: u32, max_timeout_ms: u32) -> ToolResult<()> { - if default_timeout_ms < MIN_TIMEOUT_MS { - return Err(ToolError::validation_for( - "default_timeout_ms", - format!("default_timeout_ms must be >= {}", MIN_TIMEOUT_MS), - )); - } - if max_timeout_ms < MIN_TIMEOUT_MS { - return Err(ToolError::validation_for( - "max_timeout_ms", - format!("max_timeout_ms must be >= {}", MIN_TIMEOUT_MS), - )); - } - if default_timeout_ms > max_timeout_ms { - return Err(ToolError::validation_for( - "default_timeout_ms", - format!( - "default_timeout_ms ({default_timeout_ms}) must be <= max_timeout_ms ({max_timeout_ms})" - ), - )); +impl Default for WebFetchSettings { + fn default() -> Self { + Self::new() } - Ok(()) } -/// Result from URL fetch operation. -#[derive(Debug, Clone)] -pub struct WebFetchOutput { - /// The processed content (HTML converted to markdown, JSON prettified). - pub content: String, - /// The Content-Type header value. - pub content_type: String, - /// Original byte length before processing. - pub byte_length: usize, +/// Formats JSON content for readability. +pub fn format_json(json_str: &str) -> String { + match serde_json::from_str::(json_str) { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), + Err(_) => json_str.to_string(), + } } -/// Processes raw response content based on content type. -pub(crate) fn process_content(raw_content: &str, content_type: &str) -> String { - if content_type.contains("text/html") { - html_to_markdown(raw_content) - } else if content_type.contains("application/json") { - format_json(raw_content) - } else { - raw_content.to_owned() - } +/// Converts HTML to markdown for LLM-friendly output. +pub fn html_to_markdown(html: &str) -> String { + let options = ConversionOptions { + preprocessing: PreprocessingOptions { + enabled: true, + preset: PreprocessingPreset::Aggressive, + remove_navigation: true, + remove_forms: true, + }, + strip_tags: vec![ + "img".into(), + "svg".into(), + "script".into(), + "style".into(), + "noscript".into(), + ], + ..Default::default() + }; + + convert(html, Some(options)) + .ok() + .and_then(|result: ConversionResult| result.content) + .unwrap_or_else(|| html.to_string()) } /// Categorises reqwest errors into appropriate [`ToolError`] variants. @@ -223,49 +230,41 @@ pub(crate) fn check_size(len: usize, url: &str, max_size: usize) -> ToolResult<( Ok(()) } -/// Converts HTML to markdown for LLM-friendly output. -pub fn html_to_markdown(html: &str) -> String { - let options = ConversionOptions { - preprocessing: PreprocessingOptions { - enabled: true, - preset: PreprocessingPreset::Aggressive, - remove_navigation: true, - remove_forms: true, - }, - strip_tags: vec![ - "img".into(), - "svg".into(), - "script".into(), - "style".into(), - "noscript".into(), - ], - ..Default::default() - }; - - convert(html, Some(options)) - .ok() - .and_then(|result: ConversionResult| result.content) - .unwrap_or_else(|| html.to_string()) +/// Processes raw response content based on content type. +pub(crate) fn process_content(raw_content: &str, content_type: &str) -> String { + if content_type.contains("text/html") { + html_to_markdown(raw_content) + } else if content_type.contains("application/json") { + format_json(raw_content) + } else { + raw_content.to_owned() + } } -/// Formats JSON content for readability. -pub fn format_json(json_str: &str) -> String { - match serde_json::from_str::(json_str) { - Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), - Err(_) => json_str.to_string(), +fn ensure_timeouts(default_timeout_ms: u32, max_timeout_ms: u32) -> ToolResult<()> { + if default_timeout_ms < MIN_TIMEOUT_MS { + return Err(ToolError::validation_for( + "default_timeout_ms", + format!("default_timeout_ms must be >= {}", MIN_TIMEOUT_MS), + )); } + if max_timeout_ms < MIN_TIMEOUT_MS { + return Err(ToolError::validation_for( + "max_timeout_ms", + format!("max_timeout_ms must be >= {}", MIN_TIMEOUT_MS), + )); + } + if default_timeout_ms > max_timeout_ms { + return Err(ToolError::validation_for( + "default_timeout_ms", + format!( + "default_timeout_ms ({default_timeout_ms}) must be <= max_timeout_ms ({max_timeout_ms})" + ), + )); + } + Ok(()) } -#[cfg(feature = "tokio")] -mod tokio_impl; -#[cfg(feature = "tokio")] -pub use tokio_impl::fetch_url; - -#[cfg(all(feature = "blocking", not(feature = "tokio")))] -mod blocking_impl; -#[cfg(all(feature = "blocking", not(feature = "tokio")))] -pub use blocking_impl::fetch_url; - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-core/src/tools/write.rs b/src/reloaded-code-core/src/tools/write.rs index c381b42a..5310bf6a 100644 --- a/src/reloaded-code-core/src/tools/write.rs +++ b/src/reloaded-code-core/src/tools/write.rs @@ -13,6 +13,12 @@ pub struct WriteRequest { pub content: String, } +/// Runtime settings for write requests. +/// +/// Reserved for future use. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WriteSettings {} + impl WriteRequest { /// Parses a raw JSON tool payload into a write request. /// @@ -25,12 +31,6 @@ impl WriteRequest { } } -/// Runtime settings for write requests. -/// -/// Reserved for future use. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WriteSettings {} - impl WriteSettings { /// Creates default write settings. #[must_use] diff --git a/src/reloaded-code-core/src/util.rs b/src/reloaded-code-core/src/util.rs index f30a8600..92e554bf 100644 --- a/src/reloaded-code-core/src/util.rs +++ b/src/reloaded-code-core/src/util.rs @@ -2,18 +2,50 @@ /// Generous estimate of average characters per line for buffer pre-allocation. pub const ESTIMATED_CHARS_PER_LINE: usize = 64; - -/// Suffix added to truncated lines. -pub(crate) const TRUNCATION_ELLIPSIS: &str = "..."; - -/// Minimum characters per output line when using `...` truncation. -pub const MIN_LINE_LENGTH: usize = TRUNCATION_ELLIPSIS.len() + 1; - /// Minimum value for limit/count fields (e.g., read.limit, grep.limit, glob.limit). pub const MIN_LIMIT: usize = 1; - +/// Minimum characters per output line when using `...` truncation. +pub const MIN_LINE_LENGTH: usize = TRUNCATION_ELLIPSIS.len() + 1; /// Minimum value for timeout fields in milliseconds (e.g., bash.timeout_ms, webfetch.timeout_ms). pub const MIN_TIMEOUT_MS: u32 = 1000; +/// Suffix added to truncated lines. +pub(crate) const TRUNCATION_ELLIPSIS: &str = "..."; + +/// Appends `n` right-aligned with leading spaces to fill `width` characters. +/// E.g. `push_padded_usize(buf, 5, 4)` appends `" 5"`. +/// +/// When `width` equals the digit count of `n`, this appends just the digits +/// (no padding), equivalent to a plain integer-to-string conversion. +/// +/// # Safety (caller contract) +/// +/// `width` must be >= the number of digits in `n`. This is guaranteed by +/// construction: callers compute `width` from the maximum line number or +/// from the number's own digit count. +#[inline] +pub(crate) fn push_padded_usize(output: &mut String, n: usize, width: usize) { + debug_assert!(width <= 20, "width exceeds stack buffer"); + let mut buf = [b' '; 20]; + let mut pos = 20usize; + let mut m = n; + if m == 0 { + pos -= 1; + buf[pos] = b'0'; + } else { + while m > 0 { + pos -= 1; + buf[pos] = b'0' + (m % 10) as u8; + m /= 10; + } + } + // `width >= digit_count(n)` by contract, so `20 - width <= pos`. + // buf[20-width..pos] is already spaces; buf[pos..20] has digits. + let start = 20 - width; + debug_assert!(start <= pos, "width ({width}) < digit count of {n}"); + unsafe { + output.push_str(core::str::from_utf8_unchecked(&buf[start..])); + } +} /// Truncates a line for display with a trailing [`TRUNCATION_ELLIPSIS`]. /// @@ -69,42 +101,6 @@ pub(crate) fn truncate_line_with_ellipsis(line: &str, max_chars: usize) -> (&str } } -/// Appends `n` right-aligned with leading spaces to fill `width` characters. -/// E.g. `push_padded_usize(buf, 5, 4)` appends `" 5"`. -/// -/// When `width` equals the digit count of `n`, this appends just the digits -/// (no padding), equivalent to a plain integer-to-string conversion. -/// -/// # Safety (caller contract) -/// -/// `width` must be >= the number of digits in `n`. This is guaranteed by -/// construction: callers compute `width` from the maximum line number or -/// from the number's own digit count. -#[inline] -pub(crate) fn push_padded_usize(output: &mut String, n: usize, width: usize) { - debug_assert!(width <= 20, "width exceeds stack buffer"); - let mut buf = [b' '; 20]; - let mut pos = 20usize; - let mut m = n; - if m == 0 { - pos -= 1; - buf[pos] = b'0'; - } else { - while m > 0 { - pos -= 1; - buf[pos] = b'0' + (m % 10) as u8; - m /= 10; - } - } - // `width >= digit_count(n)` by contract, so `20 - width <= pos`. - // buf[20-width..pos] is already spaces; buf[pos..20] has digits. - let start = 20 - width; - debug_assert!(start <= pos, "width ({width}) < digit count of {n}"); - unsafe { - output.push_str(core::str::from_utf8_unchecked(&buf[start..])); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-models-dev/src/api/catalog_sources.rs b/src/reloaded-code-models-dev/src/api/catalog_sources.rs index 0179e263..6886d003 100644 --- a/src/reloaded-code-models-dev/src/api/catalog_sources.rs +++ b/src/reloaded-code-models-dev/src/api/catalog_sources.rs @@ -95,6 +95,38 @@ fn model_info_from_entry(model_entry: &ApiModelEntry) -> ModelInfo { } } +#[inline] +fn provider_type_from_models_dev_npm(npm_package: Option<&str>) -> ProviderType { + match npm_package { + Some("@ai-sdk/openai") => ProviderType::OpenAiCompletions, + Some("@ai-sdk/openai-compatible") => ProviderType::OpenAiCompletions, + Some("@ai-sdk/openai-responses") => ProviderType::OpenAiResponses, + Some("@ai-sdk/anthropic") => ProviderType::Anthropic, + Some("@ai-sdk/google") => ProviderType::Google, + Some("@ai-sdk/groq") => ProviderType::Groq, + Some("@ai-sdk/mistral") => ProviderType::Mistral, + Some("@ai-sdk/ollama") => ProviderType::Ollama, + Some("@ai-sdk/amazon-bedrock") => ProviderType::Bedrock, + Some("@ai-sdk/azure") => ProviderType::Azure, + Some("@openrouter/ai-sdk-provider") => ProviderType::OpenRouter, + Some("@ai-sdk/huggingface") => ProviderType::HuggingFace, + Some("@ai-sdk/cohere") => ProviderType::Cohere, + Some("@ai-sdk/chatgpt-oauth") => ProviderType::ChatGptOAuth, + Some("@ai-sdk/claude-code-oauth") => ProviderType::ClaudeCodeOAuth, + Some("@ai-sdk/antigravity") => ProviderType::Antigravity, + Some(_) | None => ProviderType::Unknown, + } +} + +#[inline] +fn model_max_input(limit: &ApiModelLimit) -> u32 { + if limit.input == 0 { + limit.context + } else { + limit.input + } +} + #[inline] fn model_modalities(raw: Option<&ApiModelModalities>) -> Modality { let Some(raw) = raw else { @@ -134,38 +166,6 @@ fn output_modality_flag(label: &str) -> Modality { } } -#[inline] -fn model_max_input(limit: &ApiModelLimit) -> u32 { - if limit.input == 0 { - limit.context - } else { - limit.input - } -} - -#[inline] -fn provider_type_from_models_dev_npm(npm_package: Option<&str>) -> ProviderType { - match npm_package { - Some("@ai-sdk/openai") => ProviderType::OpenAiCompletions, - Some("@ai-sdk/openai-compatible") => ProviderType::OpenAiCompletions, - Some("@ai-sdk/openai-responses") => ProviderType::OpenAiResponses, - Some("@ai-sdk/anthropic") => ProviderType::Anthropic, - Some("@ai-sdk/google") => ProviderType::Google, - Some("@ai-sdk/groq") => ProviderType::Groq, - Some("@ai-sdk/mistral") => ProviderType::Mistral, - Some("@ai-sdk/ollama") => ProviderType::Ollama, - Some("@ai-sdk/amazon-bedrock") => ProviderType::Bedrock, - Some("@ai-sdk/azure") => ProviderType::Azure, - Some("@openrouter/ai-sdk-provider") => ProviderType::OpenRouter, - Some("@ai-sdk/huggingface") => ProviderType::HuggingFace, - Some("@ai-sdk/cohere") => ProviderType::Cohere, - Some("@ai-sdk/chatgpt-oauth") => ProviderType::ChatGptOAuth, - Some("@ai-sdk/claude-code-oauth") => ProviderType::ClaudeCodeOAuth, - Some("@ai-sdk/antigravity") => ProviderType::Antigravity, - Some(_) | None => ProviderType::Unknown, - } -} - #[cfg(test)] mod tests { use super::{cache_payload_from_api_json_bytes, provider_type_from_models_dev_npm}; diff --git a/src/reloaded-code-models-dev/src/api/schema.rs b/src/reloaded-code-models-dev/src/api/schema.rs index 77eeb377..52e3167d 100644 --- a/src/reloaded-code-models-dev/src/api/schema.rs +++ b/src/reloaded-code-models-dev/src/api/schema.rs @@ -64,14 +64,6 @@ pub(crate) struct ApiModelEntry { pub(crate) modalities: Option, } -#[derive(Debug, Deserialize)] -pub(crate) struct ApiModelModalities { - #[serde(default)] - pub(crate) input: Vec, - #[serde(default)] - pub(crate) output: Vec, -} - #[derive(Debug, Deserialize)] pub(crate) struct ApiModelLimit { #[serde(default)] @@ -82,6 +74,14 @@ pub(crate) struct ApiModelLimit { pub(crate) output: u32, } +#[derive(Debug, Deserialize)] +pub(crate) struct ApiModelModalities { + #[serde(default)] + pub(crate) input: Vec, + #[serde(default)] + pub(crate) output: Vec, +} + /// Parses upstream `api.json` bytes into a provider map. /// /// Input must match the current models.dev shape: a flat top-level object where diff --git a/src/reloaded-code-models-dev/src/cache/format.rs b/src/reloaded-code-models-dev/src/cache/format.rs index 8059ac1b..7580411d 100644 --- a/src/reloaded-code-models-dev/src/cache/format.rs +++ b/src/reloaded-code-models-dev/src/cache/format.rs @@ -44,6 +44,25 @@ use std::mem::size_of; use std::path::Path; use std::ptr::copy_nonoverlapping; +/// Fixed prelude size for v1. +const CACHE_HEADER_LEN: usize = ::SIZE; +// SAFETY: All modern platforms have usize >= 32 bits. +// This lets us safely cast u32 lengths to usize without checked arithmetic. +const _: () = assert!(size_of::() >= size_of::()); + +/// Raw cache blocks extracted from disk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CacheFileData { + /// Prefix length of ETag bytes after the fixed prelude. + etag_len: u32, + /// Length in bytes of compressed payload from prelude. + payload_len_compressed: u32, + /// Size hint for the eventual decompressed payload allocation. + payload_len_decompressed: u32, + /// Full file bytes laid out as `prelude || etag || payload_compressed`. + file_bytes: Box<[u8]>, +} + /// Fixed v1 prelude, encoded little-endian. #[derive(Debug, Clone, Copy, PartialEq, Eq, EndianWritable)] #[repr(C)] @@ -67,26 +86,6 @@ pub(crate) struct CacheWriteInput<'a> { pub(crate) payload_len_decompressed: usize, } -/// Fixed prelude size for v1. -const CACHE_HEADER_LEN: usize = ::SIZE; - -// SAFETY: All modern platforms have usize >= 32 bits. -// This lets us safely cast u32 lengths to usize without checked arithmetic. -const _: () = assert!(size_of::() >= size_of::()); - -/// Raw cache blocks extracted from disk. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CacheFileData { - /// Prefix length of ETag bytes after the fixed prelude. - etag_len: u32, - /// Length in bytes of compressed payload from prelude. - payload_len_compressed: u32, - /// Size hint for the eventual decompressed payload allocation. - payload_len_decompressed: u32, - /// Full file bytes laid out as `prelude || etag || payload_compressed`. - file_bytes: Box<[u8]>, -} - impl CacheFileData { /// Returns the optional ETag as a borrowed byte slice. #[inline] @@ -258,9 +257,14 @@ pub(crate) async fn write_cache_file( Ok(()) } +/// Decodes prelude from little-endian bytes. #[inline] -fn to_u32_limit(value: usize, msg: &'static str) -> CatalogResult { - u32::try_from(value).map_err(|_| CatalogError::CacheFormat(msg)) +fn decode_prelude(bytes: &[u8]) -> CachePreludeV1 { + // SAFETY: Caller guarantees `bytes` is at least `CACHE_HEADER_LEN`. + unsafe { + let mut reader = LittleEndianReader::new(bytes.as_ptr()); + reader.read() + } } /// Encodes prelude into little-endian bytes. @@ -275,14 +279,9 @@ fn encode_prelude(prelude: CachePreludeV1) -> [u8; CACHE_HEADER_LEN] { bytes } -/// Decodes prelude from little-endian bytes. #[inline] -fn decode_prelude(bytes: &[u8]) -> CachePreludeV1 { - // SAFETY: Caller guarantees `bytes` is at least `CACHE_HEADER_LEN`. - unsafe { - let mut reader = LittleEndianReader::new(bytes.as_ptr()); - reader.read() - } +fn to_u32_limit(value: usize, msg: &'static str) -> CatalogResult { + u32::try_from(value).map_err(|_| CatalogError::CacheFormat(msg)) } #[cfg(test)] diff --git a/src/reloaded-code-models-dev/src/cache/mod.rs b/src/reloaded-code-models-dev/src/cache/mod.rs index 43af19b8..e839845f 100644 --- a/src/reloaded-code-models-dev/src/cache/mod.rs +++ b/src/reloaded-code-models-dev/src/cache/mod.rs @@ -12,9 +12,9 @@ //! The public API currently exposes path resolution only; container helpers are //! crate-internal until the sync/load flow is wired. +pub use crate::error::CatalogResult; +pub use path::{shared_cache_path, CACHE_PATH_ENV_VAR}; + pub(crate) mod format; mod path; pub(crate) mod payload; - -pub use crate::error::CatalogResult; -pub use path::{shared_cache_path, CACHE_PATH_ENV_VAR}; diff --git a/src/reloaded-code-models-dev/src/cache/path.rs b/src/reloaded-code-models-dev/src/cache/path.rs index d38e29b0..b79c98f1 100644 --- a/src/reloaded-code-models-dev/src/cache/path.rs +++ b/src/reloaded-code-models-dev/src/cache/path.rs @@ -3,11 +3,10 @@ use crate::{error::CatalogResult, CatalogError}; use std::path::PathBuf; +const CACHE_FILENAME: &str = "models.dev.catalog.v1.cache"; /// Environment variable name for overriding the default cache path. pub const CACHE_PATH_ENV_VAR: &str = "RELOADED_CODE_MODELS_DEV_CACHE_PATH"; - const CACHE_SUBDIR: &str = "reloaded-code"; -const CACHE_FILENAME: &str = "models.dev.catalog.v1.cache"; /// Returns the shared cache path for the models.dev catalog. /// diff --git a/src/reloaded-code-models-dev/src/cache/payload.rs b/src/reloaded-code-models-dev/src/cache/payload.rs index 2118fe0a..16cc8469 100644 --- a/src/reloaded-code-models-dev/src/cache/payload.rs +++ b/src/reloaded-code-models-dev/src/cache/payload.rs @@ -9,33 +9,33 @@ //! Using a 1.26 MB `api.json` snapshot (models.dev), converted to bitcode //! then compressed with zstd at various levels: //! -//! | Level | Size | % of JSON | Time | -//! |----------------|-----------|-----------|---------| -//! | JSON | 1260.7 KB | 100.00% | - | -//! | (raw bitcode) | 105.7 KB | 8.39% | - | -//! | 0 | 29.7 KB | 2.36% | 1.4ms | -//! | 1 | 32.1 KB | 2.55% | 1.0ms | -//! | 2 | 31.7 KB | 2.51% | 1.0ms | -//! | 3 | 29.7 KB | 2.36% | 1.1ms | -//! | 4 | 29.7 KB | 2.36% | 1.9ms | -//! | 5 | 27.5 KB | 2.18% | 2.9ms | -//! | 6 | 27.1 KB | 2.15% | 3.6ms | -//! | 7 | 26.6 KB | 2.11% | 4.8ms | -//! | 8 | 26.7 KB | 2.12% | 5.0ms | -//! | 9 | 26.7 KB | 2.12% | 6.3ms | -//! | 10 | 26.4 KB | 2.09% | 9.1ms | -//! | 11 | 26.1 KB | 2.07% | 8.5ms | -//! | 12 | 26.1 KB | 2.07% | 14.4ms | -//! | 13 | 26.0 KB | 2.06% | 12.0ms | -//! | 14 | 26.0 KB | 2.06% | 16.4ms | -//! | 15 | 25.9 KB | 2.06% | 21.6ms | -//! | 16 | 23.6 KB | 1.87% | 24.2ms | -//! | 17 | 23.2 KB | 1.84% | 27.6ms | -//! | 18 | 23.2 KB | 1.84% | 42.6ms | -//! | 19 | 23.1 KB | 1.83% | 81.3ms | -//! | 20 | 23.1 KB | 1.83% | 96.3ms | -//! | 21 | 23.1 KB | 1.83% | 125.4ms | -//! | 22 | 23.1 KB | 1.83% | 207.5ms | +//! | Level | Size | % of JSON | Time | +//! | ------------- | --------- | --------- | ------- | +//! | JSON | 1260.7 KB | 100.00% | - | +//! | (raw bitcode) | 105.7 KB | 8.39% | - | +//! | 0 | 29.7 KB | 2.36% | 1.4ms | +//! | 1 | 32.1 KB | 2.55% | 1.0ms | +//! | 2 | 31.7 KB | 2.51% | 1.0ms | +//! | 3 | 29.7 KB | 2.36% | 1.1ms | +//! | 4 | 29.7 KB | 2.36% | 1.9ms | +//! | 5 | 27.5 KB | 2.18% | 2.9ms | +//! | 6 | 27.1 KB | 2.15% | 3.6ms | +//! | 7 | 26.6 KB | 2.11% | 4.8ms | +//! | 8 | 26.7 KB | 2.12% | 5.0ms | +//! | 9 | 26.7 KB | 2.12% | 6.3ms | +//! | 10 | 26.4 KB | 2.09% | 9.1ms | +//! | 11 | 26.1 KB | 2.07% | 8.5ms | +//! | 12 | 26.1 KB | 2.07% | 14.4ms | +//! | 13 | 26.0 KB | 2.06% | 12.0ms | +//! | 14 | 26.0 KB | 2.06% | 16.4ms | +//! | 15 | 25.9 KB | 2.06% | 21.6ms | +//! | 16 | 23.6 KB | 1.87% | 24.2ms | +//! | 17 | 23.2 KB | 1.84% | 27.6ms | +//! | 18 | 23.2 KB | 1.84% | 42.6ms | +//! | 19 | 23.1 KB | 1.83% | 81.3ms | +//! | 20 | 23.1 KB | 1.83% | 96.3ms | +//! | 21 | 23.1 KB | 1.83% | 125.4ms | +//! | 22 | 23.1 KB | 1.83% | 207.5ms | //! //! Levels 1-3 offer the best speed/ratio tradeoff (~1ms, ~2.4% of JSON). //! Levels 19-22 provide maximal compression but take 80-200ms. @@ -55,19 +55,6 @@ pub(crate) struct CatalogCachePayload { pub(crate) models: Vec, } -/// Serializable provider row stored in the cache payload. -#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)] -pub(crate) struct CachedProviderRow { - /// Stable provider lookup key. - pub(crate) provider_key: String, - /// Base API URL for requests to this provider. - pub(crate) api_url: String, - /// Environment variables that can supply credentials. - pub(crate) env_vars: Vec, - /// Provider protocol or API shape. - pub(crate) api_type: ProviderType, -} - /// Serializable model row stored in the cache payload. #[derive(Debug, Clone, PartialEq, bitcode::Encode, bitcode::Decode)] pub(crate) struct CachedModelRow { @@ -87,19 +74,17 @@ pub(crate) struct CachedModelRow { pub(crate) top_p: Option, } -/// Encodes a cache payload into bitcode bytes. -pub(crate) fn encode_cache_payload(payload: &CatalogCachePayload) -> Vec { - bitcode::encode(payload) -} - -/// Decodes bitcode bytes into an owned cache payload. -/// -/// # Errors -/// -/// Returns [`CatalogError::BitcodeDecode`] when the bytes are not a valid cache -/// payload encoding. -pub(crate) fn decode_cache_payload(bytes: &[u8]) -> CatalogResult { - bitcode::decode(bytes).map_err(|error| CatalogError::BitcodeDecode(error.to_string())) +/// Serializable provider row stored in the cache payload. +#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)] +pub(crate) struct CachedProviderRow { + /// Stable provider lookup key. + pub(crate) provider_key: String, + /// Base API URL for requests to this provider. + pub(crate) api_url: String, + /// Environment variables that can supply credentials. + pub(crate) env_vars: Vec, + /// Provider protocol or API shape. + pub(crate) api_type: ProviderType, } /// Rebuilds a [`ModelCatalog`] from decoded cache rows. @@ -142,6 +127,21 @@ pub(crate) fn catalog_from_cache_payload( Ok(ModelCatalog::build(&provider_sources, &model_sources)?) } +/// Decodes bitcode bytes into an owned cache payload. +/// +/// # Errors +/// +/// Returns [`CatalogError::BitcodeDecode`] when the bytes are not a valid cache +/// payload encoding. +pub(crate) fn decode_cache_payload(bytes: &[u8]) -> CatalogResult { + bitcode::decode(bytes).map_err(|error| CatalogError::BitcodeDecode(error.to_string())) +} + +/// Encodes a cache payload into bitcode bytes. +pub(crate) fn encode_cache_payload(payload: &CatalogCachePayload) -> Vec { + bitcode::encode(payload) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-models-dev/src/catalog/mod.rs b/src/reloaded-code-models-dev/src/catalog/mod.rs index f93d908e..36ba0bd0 100644 --- a/src/reloaded-code-models-dev/src/catalog/mod.rs +++ b/src/reloaded-code-models-dev/src/catalog/mod.rs @@ -5,19 +5,15 @@ //! - Reuse cache on `304 Not Modified` //! - Fall back to cached data if the network path fails -mod load_cache; -mod load_result; -mod sync; - -#[cfg(test)] -mod test_utils; - -pub use load_result::{CatalogLoadResult, CatalogLoadSource}; - use crate::cache::shared_cache_path; use crate::error::CatalogError; +pub use load_result::{CatalogLoadResult, CatalogLoadSource}; use std::path::Path; +mod load_cache; +mod load_result; +mod sync; + /// Entry point for loading models.dev catalogs. /// /// This struct provides static methods for loading the catalog either @@ -154,6 +150,8 @@ impl ModelsDevCatalog { } } +#[cfg(test)] +mod test_utils; #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-models-dev/src/catalog/sync.rs b/src/reloaded-code-models-dev/src/catalog/sync.rs index 45bee4d2..1d369083 100644 --- a/src/reloaded-code-models-dev/src/catalog/sync.rs +++ b/src/reloaded-code-models-dev/src/catalog/sync.rs @@ -20,54 +20,11 @@ use std::path::Path; /// Default production endpoint for the models.dev catalog snapshot. const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; - /// Timeout for HTTP connections and requests in seconds. const REQUEST_TIMEOUT_SECS: u64 = 30; - #[cfg(test)] static TEST_MODELS_DEV_API_URL: std::sync::Mutex> = std::sync::Mutex::new(None); -#[cfg(test)] -/// Overrides the remote catalog URL for sync tests. -pub(crate) fn set_test_models_dev_api_url(url: Option) { - *TEST_MODELS_DEV_API_URL.lock().unwrap() = url; -} - -/// Returns the active catalog endpoint, including the test override when set. -fn models_dev_api_url() -> Cow<'static, str> { - #[cfg(test)] - if let Some(url) = TEST_MODELS_DEV_API_URL.lock().unwrap().clone() { - return Cow::Owned(url); - } - - Cow::Borrowed(MODELS_DEV_API_URL) -} - -/// Resolves the result to return after a transient request failure. -/// -/// Cached data takes precedence over surfacing the request error so callers can -/// continue with the last known-good catalog when possible. -fn load_after_request_failure( - request_error: reqwest::Error, - cache_file: Option<&CacheFileData>, - cache_error: Option, -) -> CatalogResult { - if let Some(cache_file) = cache_file { - return load_catalog_from_cache_file_data(cache_file, CatalogLoadSource::FallbackCache); - } - - if let Some(cache_error) = cache_error { - return Err(cache_error); - } - - Err(CatalogError::Reqwest(request_error)) -} - -#[inline] -fn is_transient_status(status: StatusCode) -> bool { - status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() -} - #[maybe_async::maybe_async] /// Loads the catalog at `path` using the default models.dev endpoint. /// @@ -88,6 +45,12 @@ pub(crate) async fn load_catalog_at_path(path: &Path) -> CatalogResult) { + *TEST_MODELS_DEV_API_URL.lock().unwrap() = url; +} + #[maybe_async::maybe_async] /// Synchronizes the cache at `path` against `url` and returns a catalog. /// @@ -218,6 +181,41 @@ pub(crate) async fn load_catalog_from_url( } } +#[inline] +fn is_transient_status(status: StatusCode) -> bool { + status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + +/// Resolves the result to return after a transient request failure. +/// +/// Cached data takes precedence over surfacing the request error so callers can +/// continue with the last known-good catalog when possible. +fn load_after_request_failure( + request_error: reqwest::Error, + cache_file: Option<&CacheFileData>, + cache_error: Option, +) -> CatalogResult { + if let Some(cache_file) = cache_file { + return load_catalog_from_cache_file_data(cache_file, CatalogLoadSource::FallbackCache); + } + + if let Some(cache_error) = cache_error { + return Err(cache_error); + } + + Err(CatalogError::Reqwest(request_error)) +} + +/// Returns the active catalog endpoint, including the test override when set. +fn models_dev_api_url() -> Cow<'static, str> { + #[cfg(test)] + if let Some(url) = TEST_MODELS_DEV_API_URL.lock().unwrap().clone() { + return Cow::Owned(url); + } + + Cow::Borrowed(MODELS_DEV_API_URL) +} + #[cfg(test)] mod tests { use super::super::test_utils::{sample_api_json, start_mock_server, MockResponse}; diff --git a/src/reloaded-code-models-dev/src/error.rs b/src/reloaded-code-models-dev/src/error.rs index da107055..91520b68 100644 --- a/src/reloaded-code-models-dev/src/error.rs +++ b/src/reloaded-code-models-dev/src/error.rs @@ -3,6 +3,9 @@ use reloaded_code_core::models::ModelCatalogBuildError; use thiserror::Error; +/// Convenience type alias for catalog operations. +pub type CatalogResult = Result; + /// Errors that can occur during catalog loading and synchronization. #[derive(Debug, Error)] pub enum CatalogError { @@ -47,6 +50,3 @@ pub enum CatalogError { #[error("blocking task failed: {0}")] JoinHandle(#[from] tokio::task::JoinError), } - -/// Convenience type alias for catalog operations. -pub type CatalogResult = Result; diff --git a/src/reloaded-code-models-dev/src/fs/blocking_impl.rs b/src/reloaded-code-models-dev/src/fs/blocking_impl.rs index 3dc6f62c..06ec81bb 100644 --- a/src/reloaded-code-models-dev/src/fs/blocking_impl.rs +++ b/src/reloaded-code-models-dev/src/fs/blocking_impl.rs @@ -3,6 +3,12 @@ use std::io::{ErrorKind, Read as _}; use std::path::Path; +/// Creates a directory and all parent directories. +#[inline] +pub(crate) fn create_dir_all(path: impl AsRef) -> std::io::Result<()> { + std::fs::create_dir_all(path) +} + /// Reads a file into memory in one pre-sized allocation. /// /// # Safety @@ -28,9 +34,3 @@ pub(crate) fn read(path: impl AsRef) -> std::io::Result> { Ok(super::assume_init_u8_slice(bytes)) } - -/// Creates a directory and all parent directories. -#[inline] -pub(crate) fn create_dir_all(path: impl AsRef) -> std::io::Result<()> { - std::fs::create_dir_all(path) -} diff --git a/src/reloaded-code-models-dev/src/fs/mod.rs b/src/reloaded-code-models-dev/src/fs/mod.rs index e08a6304..ea374b82 100644 --- a/src/reloaded-code-models-dev/src/fs/mod.rs +++ b/src/reloaded-code-models-dev/src/fs/mod.rs @@ -5,28 +5,29 @@ //! - `tokio`: Async operations using the tokio runtime //! - `blocking`: Synchronous operations -use std::mem::MaybeUninit; - #[cfg(all(feature = "tokio", feature = "blocking"))] compile_error!("Features `tokio` and `blocking` are mutually exclusive."); #[cfg(not(any(feature = "tokio", feature = "blocking")))] compile_error!("Either `tokio` or `blocking` feature must be enabled for the fs module."); +#[cfg(feature = "blocking")] +pub(crate) use blocking_impl::*; +use std::mem::MaybeUninit; +#[cfg(feature = "tokio")] +pub(crate) use tokio_impl::*; + +#[cfg(feature = "blocking")] +mod blocking_impl; +#[cfg(feature = "tokio")] +mod tokio_impl; + /// Allocates an uninitialized boxed byte slice with logical length `len`. #[inline] pub(crate) fn alloc_uninit_u8_slice(len: usize) -> Box<[MaybeUninit]> { Box::<[u8]>::new_uninit_slice(len) } -/// Views an uninitialized `u8` slice as mutable bytes for initialization. -#[inline] -pub(crate) fn uninit_u8_slice_as_mut_bytes(bytes: &mut [MaybeUninit]) -> &mut [u8] { - // SAFETY: `MaybeUninit` has identical layout to `u8`; caller only uses - // returned slice for writes before reading. - unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr().cast::(), bytes.len()) } -} - /// Converts a fully-initialized boxed uninitialized slice into initialized bytes. #[inline] pub(crate) fn assume_init_u8_slice(bytes: Box<[MaybeUninit]>) -> Box<[u8]> { @@ -34,12 +35,10 @@ pub(crate) fn assume_init_u8_slice(bytes: Box<[MaybeUninit]>) -> Box<[u8]> { unsafe { bytes.assume_init() } } -#[cfg(feature = "tokio")] -mod tokio_impl; -#[cfg(feature = "tokio")] -pub(crate) use tokio_impl::*; - -#[cfg(feature = "blocking")] -mod blocking_impl; -#[cfg(feature = "blocking")] -pub(crate) use blocking_impl::*; +/// Views an uninitialized `u8` slice as mutable bytes for initialization. +#[inline] +pub(crate) fn uninit_u8_slice_as_mut_bytes(bytes: &mut [MaybeUninit]) -> &mut [u8] { + // SAFETY: `MaybeUninit` has identical layout to `u8`; caller only uses + // returned slice for writes before reading. + unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr().cast::(), bytes.len()) } +} diff --git a/src/reloaded-code-models-dev/src/fs/tokio_impl.rs b/src/reloaded-code-models-dev/src/fs/tokio_impl.rs index 92bca908..de1e52e0 100644 --- a/src/reloaded-code-models-dev/src/fs/tokio_impl.rs +++ b/src/reloaded-code-models-dev/src/fs/tokio_impl.rs @@ -4,6 +4,12 @@ use std::io::ErrorKind; use std::path::Path; use tokio::io::AsyncReadExt as _; +/// Creates a directory and all parent directories. +#[inline] +pub(crate) async fn create_dir_all(path: impl AsRef) -> std::io::Result<()> { + tokio::fs::create_dir_all(path).await +} + /// Reads a file into memory in one pre-sized allocation. /// /// # Safety @@ -29,9 +35,3 @@ pub(crate) async fn read(path: impl AsRef) -> std::io::Result> { Ok(super::assume_init_u8_slice(bytes)) } - -/// Creates a directory and all parent directories. -#[inline] -pub(crate) async fn create_dir_all(path: impl AsRef) -> std::io::Result<()> { - tokio::fs::create_dir_all(path).await -} diff --git a/src/reloaded-code-models-dev/src/lib.rs b/src/reloaded-code-models-dev/src/lib.rs index 60fef51e..1f98c4fe 100644 --- a/src/reloaded-code-models-dev/src/lib.rs +++ b/src/reloaded-code-models-dev/src/lib.rs @@ -10,12 +10,12 @@ compile_error!(concat!( "must be enabled." )); +pub use cache::shared_cache_path; +pub use catalog::{CatalogLoadResult, CatalogLoadSource, ModelsDevCatalog}; +pub use error::{CatalogError, CatalogResult}; + mod api; pub mod cache; pub mod catalog; pub mod error; mod fs; - -pub use cache::shared_cache_path; -pub use catalog::{CatalogLoadResult, CatalogLoadSource, ModelsDevCatalog}; -pub use error::{CatalogError, CatalogResult}; diff --git a/src/reloaded-code-provider-config/src/api_type.rs b/src/reloaded-code-provider-config/src/api_type.rs index f18ef522..9a39a7c2 100644 --- a/src/reloaded-code-provider-config/src/api_type.rs +++ b/src/reloaded-code-provider-config/src/api_type.rs @@ -2,6 +2,9 @@ use reloaded_code_core::models::ProviderType; +/// Default `api_type` string used when the field is omitted from YAML. +pub const DEFAULT_API_TYPE: &str = "openai-compatible"; + /// Maps a YAML `api_type` string to a [`ProviderType`]. /// /// `openai` and `openai-compatible` both map to [`ProviderType::OpenAiCompletions`]. @@ -27,9 +30,6 @@ pub fn api_type_from_str(s: &str) -> ProviderType { } } -/// Default `api_type` string used when the field is omitted from YAML. -pub const DEFAULT_API_TYPE: &str = "openai-compatible"; - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-provider-config/src/lib.rs b/src/reloaded-code-provider-config/src/lib.rs index 0b8188d9..b26e1a48 100644 --- a/src/reloaded-code-provider-config/src/lib.rs +++ b/src/reloaded-code-provider-config/src/lib.rs @@ -5,11 +5,11 @@ //! //! [`ModelCatalog::build()`]: reloaded_code_core::models::ModelCatalog::build +pub use config::{ModelConfig, ProviderConfig}; +pub use error::ProviderConfigError; +pub use loader::{default_config_paths, LoadedProviderConfig, ProviderConfigLoader}; + mod api_type; mod config; mod error; mod loader; - -pub use config::{ModelConfig, ProviderConfig}; -pub use error::ProviderConfigError; -pub use loader::{default_config_paths, LoadedProviderConfig, ProviderConfigLoader}; diff --git a/src/reloaded-code-provider-config/src/loader.rs b/src/reloaded-code-provider-config/src/loader.rs index a74037f2..e919452c 100644 --- a/src/reloaded-code-provider-config/src/loader.rs +++ b/src/reloaded-code-provider-config/src/loader.rs @@ -7,21 +7,38 @@ //! `(Vec, Vec)` pair. The model catalog //! consumes this pair. -use indexmap::IndexMap; -use std::path::{Path, PathBuf}; - use crate::api_type::{api_type_from_str, DEFAULT_API_TYPE}; use crate::config::ProviderConfig; use crate::error::ProviderConfigError; +use indexmap::IndexMap; use reloaded_code_core::models::{ Modality, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource, ProviderSource, ProviderType, }; +use std::path::{Path, PathBuf}; -const CONFIG_FILENAME: &str = "providers.yaml"; const CONFIG_DIR_NAME: &str = "reloaded-code"; +const CONFIG_FILENAME: &str = "providers.yaml"; const PROJECT_LOCAL_DIR: &str = ".reloaded"; +/// Merged, validated provider configuration ready for catalog conversion. +/// +/// Call [`Self::to_catalog_sources()`] to obtain `Result<(Vec, Vec), ProviderConfigError>` +/// that can be passed directly to [`ModelCatalog::build()`]. +/// +/// The map keys are user-chosen provider identifiers (e.g., `"my-llm"`, +/// `"local-ollama"`) that become [`ProviderSource::provider_key`] values in +/// the catalog. These are distinct from the provider names in the +/// pre-bundled catalog (hosted at models.dev) - they are custom providers +/// defined by the user. +/// +/// [`ModelCatalog::build()`]: reloaded_code_core::models::ModelCatalog::build +#[derive(Debug)] +pub struct LoadedProviderConfig { + /// Merged provider entries keyed by user-chosen provider identifier. + pub providers: IndexMap, +} + /// Builder that collects an ordered list of config sources and merges them /// into a [`LoadedProviderConfig`]. /// @@ -39,6 +56,92 @@ pub struct ProviderConfigLoader { sources: Vec, } +/// Individual source of provider configuration: a YAML file or a programmatic entry. +enum ConfigSource { + /// A YAML file on disk. + File(std::path::PathBuf), + /// A programmatic provider entry added at build time. + Programmatic { key: String, config: ProviderConfig }, +} + +impl LoadedProviderConfig { + /// Converts the merged config into catalog source types. + /// + /// # Returns + /// + /// - `Ok((Vec, Vec))`: A pair where + /// each model's [`ProviderModelSource::provider_idx`] (a [`ProviderIdx`] numeric + /// index) corresponds to its provider's position in the first vector. The + /// returned [`ProviderModelSource`] borrows `model_key` strings from `self`, so + /// `self` must outlive the sources. + /// + /// # Errors + /// + /// Returns [`ProviderConfigError::TooManyProviders`] when the number of + /// providers exceeds `u16::MAX + 1` (65,536), which is the maximum + /// addressable by [`ProviderIdx`]. + pub fn to_catalog_sources( + &self, + ) -> Result<(Vec, Vec>), ProviderConfigError> { + let provider_count = self.providers.len(); + let max = (u16::MAX as usize) + 1; + if provider_count > max { + return Err(ProviderConfigError::TooManyProviders { + count: provider_count, + max, + }); + } + let mut provider_sources = Vec::with_capacity(self.providers.len()); + let mut model_sources = Vec::new(); + + for (idx, (key, config)) in self.providers.iter().enumerate() { + // Resolve the provider type from the api_type string, defaulting to openai-compatible. + let provider_type = + api_type_from_str(config.api_type.as_deref().unwrap_or(DEFAULT_API_TYPE)); + let provider_info = ProviderInfo { + api_url: config.api_url.clone().unwrap_or_default(), + env_vars: config.env.clone().unwrap_or_default(), + api_type: provider_type, + }; + let provider_source = ProviderSource::new(key, provider_info); + let provider_idx = ProviderIdx::new(idx as u16); + + // Convert each model entry into a ProviderModelSource. + if let Some(models) = &config.models { + for (model_key, model_config) in models { + // Build the modality bitmask by OR'ing individual Modality flags; + // defaults to text-only if the model declares no modalities. + let mut modalities = Modality::empty(); + for s in &model_config.modalities { + if let Some(m) = Modality::from_label(s) { + modalities |= m; + } + } + if modalities.is_empty() { + modalities = Modality::TEXT; + } + let model_info = ModelInfo { + modalities, + max_input: model_config.max_input, + max_output: model_config.max_output, + temperature: model_config.default_temperature, + top_p: model_config.default_top_p, + }; + model_sources.push(ProviderModelSource::new( + provider_idx, + model_key, + model_info, + )); + } + } + + provider_sources.push(provider_source); + } + + Ok((provider_sources, model_sources)) + } +} + impl ProviderConfigLoader { /// Creates a loader pre-loaded with conventional config file paths. /// @@ -245,110 +348,6 @@ pub fn default_config_paths() -> Vec { paths.into_iter().filter(|p| p.exists()).collect() } -/// Merged, validated provider configuration ready for catalog conversion. -/// -/// Call [`Self::to_catalog_sources()`] to obtain `Result<(Vec, Vec), ProviderConfigError>` -/// that can be passed directly to [`ModelCatalog::build()`]. -/// -/// The map keys are user-chosen provider identifiers (e.g., `"my-llm"`, -/// `"local-ollama"`) that become [`ProviderSource::provider_key`] values in -/// the catalog. These are distinct from the provider names in the -/// pre-bundled catalog (hosted at models.dev) - they are custom providers -/// defined by the user. -/// -/// [`ModelCatalog::build()`]: reloaded_code_core::models::ModelCatalog::build -#[derive(Debug)] -pub struct LoadedProviderConfig { - /// Merged provider entries keyed by user-chosen provider identifier. - pub providers: IndexMap, -} - -impl LoadedProviderConfig { - /// Converts the merged config into catalog source types. - /// - /// # Returns - /// - /// - `Ok((Vec, Vec))`: A pair where - /// each model's [`ProviderModelSource::provider_idx`] (a [`ProviderIdx`] numeric - /// index) corresponds to its provider's position in the first vector. The - /// returned [`ProviderModelSource`] borrows `model_key` strings from `self`, so - /// `self` must outlive the sources. - /// - /// # Errors - /// - /// Returns [`ProviderConfigError::TooManyProviders`] when the number of - /// providers exceeds `u16::MAX + 1` (65,536), which is the maximum - /// addressable by [`ProviderIdx`]. - pub fn to_catalog_sources( - &self, - ) -> Result<(Vec, Vec>), ProviderConfigError> { - let provider_count = self.providers.len(); - let max = (u16::MAX as usize) + 1; - if provider_count > max { - return Err(ProviderConfigError::TooManyProviders { - count: provider_count, - max, - }); - } - let mut provider_sources = Vec::with_capacity(self.providers.len()); - let mut model_sources = Vec::new(); - - for (idx, (key, config)) in self.providers.iter().enumerate() { - // Resolve the provider type from the api_type string, defaulting to openai-compatible. - let provider_type = - api_type_from_str(config.api_type.as_deref().unwrap_or(DEFAULT_API_TYPE)); - let provider_info = ProviderInfo { - api_url: config.api_url.clone().unwrap_or_default(), - env_vars: config.env.clone().unwrap_or_default(), - api_type: provider_type, - }; - let provider_source = ProviderSource::new(key, provider_info); - let provider_idx = ProviderIdx::new(idx as u16); - - // Convert each model entry into a ProviderModelSource. - if let Some(models) = &config.models { - for (model_key, model_config) in models { - // Build the modality bitmask by OR'ing individual Modality flags; - // defaults to text-only if the model declares no modalities. - let mut modalities = Modality::empty(); - for s in &model_config.modalities { - if let Some(m) = Modality::from_label(s) { - modalities |= m; - } - } - if modalities.is_empty() { - modalities = Modality::TEXT; - } - let model_info = ModelInfo { - modalities, - max_input: model_config.max_input, - max_output: model_config.max_output, - temperature: model_config.default_temperature, - top_p: model_config.default_top_p, - }; - model_sources.push(ProviderModelSource::new( - provider_idx, - model_key, - model_info, - )); - } - } - - provider_sources.push(provider_source); - } - - Ok((provider_sources, model_sources)) - } -} - -/// Individual source of provider configuration: a YAML file or a programmatic entry. -enum ConfigSource { - /// A YAML file on disk. - File(std::path::PathBuf), - /// A programmatic provider entry added at build time. - Programmatic { key: String, config: ProviderConfig }, -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/examples/serdesai-agents.rs b/src/reloaded-code-serdesai/examples/serdesai-agents.rs index d95e2b06..4bffd7ae 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-agents.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-agents.rs @@ -16,9 +16,9 @@ use reloaded_code_serdesai::{AgentBuildContext, AgentDefaults}; use std::{path::PathBuf, sync::Arc}; const AGENT_NAME: &str = "basic/file-reader"; -const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; const API_KEY_NAME: &str = "SYNTHETIC_API_KEY"; const API_KEY_VALUE: &str = ""; // <-- Set your API key here +const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/src/reloaded-code-serdesai/examples/serdesai-basic.rs b/src/reloaded-code-serdesai/examples/serdesai-basic.rs index b83a71ac..e9249476 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-basic.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-basic.rs @@ -22,12 +22,8 @@ use std::fmt::Write; // Set your OpenAI API key here or via OPENAI_API_KEY environment variable. /// Fallback API key if env var is not set. Leave empty to require env var. const OPENAI_API_KEY: &str = ""; -const OPENAI_MODEL: &str = "hf:zai-org/GLM-4.7-Flash"; const OPENAI_BASE_URL: &str = "https://api.synthetic.new/openai/v1"; - -fn get_openai_api_key() -> String { - std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| OPENAI_API_KEY.to_string()) -} +const OPENAI_MODEL: &str = "hf:zai-org/GLM-4.7-Flash"; #[tokio::main] async fn main() -> std::result::Result<(), Box> { @@ -95,3 +91,7 @@ async fn main() -> std::result::Result<(), Box> { Ok(()) } + +fn get_openai_api_key() -> String { + std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| OPENAI_API_KEY.to_string()) +} diff --git a/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs b/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs index 68a4a47c..b84ef8a0 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs @@ -25,14 +25,10 @@ use serdes_ai_models::OpenAIChatModel; use std::fmt::Write; use std::sync::Arc; -const MODEL_ID: &str = "hf:zai-org/GLM-4.7-Flash"; -const BASE_URL: &str = "https://api.synthetic.new/openai/v1"; /// Fallback API key if env var is not set. Leave empty to require env var. const API_KEY: &str = ""; - -fn get_api_key() -> String { - std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| API_KEY.to_string()) -} +const BASE_URL: &str = "https://api.synthetic.new/openai/v1"; +const MODEL_ID: &str = "hf:zai-org/GLM-4.7-Flash"; // -- Portable custom tool (depends only on reloaded-code-core) -- @@ -150,3 +146,7 @@ async fn main() -> std::result::Result<(), Box> { Ok(()) } + +fn get_api_key() -> String { + std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| API_KEY.to_string()) +} diff --git a/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs b/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs index 1a08293e..2c84a6f0 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs @@ -21,68 +21,23 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; const AGENT_NAME: &str = "custom-tool-demo"; -const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; const API_KEY_NAME: &str = "SYNTHETIC_API_KEY"; const API_KEY_VALUE: &str = ""; // <-- Set your API key here +const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; const PROJECT_INFO_TOOL: &str = "project_info"; -#[tokio::main] -async fn main() -> Result<(), Box> { - let agents_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("examples") - .join("agents") - .join("custom-tool"); - - let mut credentials = CredentialResolver::without_env(); - if !API_KEY_VALUE.is_empty() { - credentials.set_override(API_KEY_NAME, API_KEY_VALUE); - } - - // Load model catalog from models.dev (online-first with local cache fallback). - let load_result = ModelsDevCatalog::load().await?; - println!( - "Loaded model catalog from models.dev (source: {:?})", - load_result.source - ); - - let mut catalog = AgentCatalog::new(); - AgentLoader::new().add_file(&mut catalog, agents_dir.join("custom-tool-demo.md"))?; - - let workspace_root = resolve_workspace_root()?; - let mut tools = default_tools(); - tools.push(ToolCatalogEntry::new( - PROJECT_INFO_TOOL, - ToolCatalogKind::Custom, - )); - - let runtime = AgentRuntimeBuilder::new() - .catalog(catalog) - .defaults(AgentDefaults::with_model(MODEL_ID)) - .tools(tools) - .custom_tool(ProjectInfoFactory) - .build()?; - - let build_context = AgentBuildContext::new( - Arc::new(runtime), - Arc::new(load_result.catalog), - Arc::new(credentials), - Arc::from(workspace_root.as_path()), - ); - - println!("Building `{AGENT_NAME}` with portable custom tool `{PROJECT_INFO_TOOL}`."); - let agent = build_context.build(AGENT_NAME)?; - println!("Built `{AGENT_NAME}` with {} tools.", agent.tools().len()); - - let prompt = "Call project_info with include_examples=true, then summarize what it says in three bullets."; - let response = agent.run(prompt, ()).await?; - println!("{}", response.output()); - - Ok(()) -} - /// Factory registered with the framework-agnostic runtime. struct ProjectInfoFactory; +/// The portable custom tool implementation. +/// +/// This type depends only on `reloaded-code-core`, not SerdesAI. Other framework +/// adapters can wrap the same `CustomTool` object in their native tool trait. +struct ProjectInfoTool { + workspace_root: PathBuf, + manifest_dir: PathBuf, +} + impl ToolContext for ProjectInfoFactory { fn name(&self) -> &'static str { PROJECT_INFO_TOOL @@ -104,15 +59,6 @@ impl ToolFactory for ProjectInfoFactory { } } -/// The portable custom tool implementation. -/// -/// This type depends only on `reloaded-code-core`, not SerdesAI. Other framework -/// adapters can wrap the same `CustomTool` object in their native tool trait. -struct ProjectInfoTool { - workspace_root: PathBuf, - manifest_dir: PathBuf, -} - impl ToolContext for ProjectInfoTool { fn name(&self) -> &'static str { PROJECT_INFO_TOOL @@ -175,6 +121,60 @@ impl CustomTool for ProjectInfoTool { } } +#[tokio::main] +async fn main() -> Result<(), Box> { + let agents_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("agents") + .join("custom-tool"); + + let mut credentials = CredentialResolver::without_env(); + if !API_KEY_VALUE.is_empty() { + credentials.set_override(API_KEY_NAME, API_KEY_VALUE); + } + + // Load model catalog from models.dev (online-first with local cache fallback). + let load_result = ModelsDevCatalog::load().await?; + println!( + "Loaded model catalog from models.dev (source: {:?})", + load_result.source + ); + + let mut catalog = AgentCatalog::new(); + AgentLoader::new().add_file(&mut catalog, agents_dir.join("custom-tool-demo.md"))?; + + let workspace_root = resolve_workspace_root()?; + let mut tools = default_tools(); + tools.push(ToolCatalogEntry::new( + PROJECT_INFO_TOOL, + ToolCatalogKind::Custom, + )); + + let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults::with_model(MODEL_ID)) + .tools(tools) + .custom_tool(ProjectInfoFactory) + .build()?; + + let build_context = AgentBuildContext::new( + Arc::new(runtime), + Arc::new(load_result.catalog), + Arc::new(credentials), + Arc::from(workspace_root.as_path()), + ); + + println!("Building `{AGENT_NAME}` with portable custom tool `{PROJECT_INFO_TOOL}`."); + let agent = build_context.build(AGENT_NAME)?; + println!("Built `{AGENT_NAME}` with {} tools.", agent.tools().len()); + + let prompt = "Call project_info with include_examples=true, then summarize what it says in three bullets."; + let response = agent.run(prompt, ()).await?; + println!("{}", response.output()); + + Ok(()) +} + fn list_example_files(manifest_dir: &Path) -> ToolResult> { let examples_dir = manifest_dir.join("examples"); let mut names = Vec::new(); diff --git a/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs b/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs index 511ab50d..7d990dc3 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs @@ -21,12 +21,8 @@ use std::fmt::Write; // Set your OpenAI API key here or via OPENAI_API_KEY environment variable. /// Fallback API key if env var is not set. Leave empty to require env var. const OPENAI_API_KEY: &str = ""; -const OPENAI_MODEL: &str = "hf:zai-org/GLM-4.7-Flash"; const OPENAI_BASE_URL: &str = "https://api.synthetic.new/openai/v1"; - -fn get_openai_api_key() -> String { - std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| OPENAI_API_KEY.to_string()) -} +const OPENAI_MODEL: &str = "hf:zai-org/GLM-4.7-Flash"; #[tokio::main] async fn main() -> std::result::Result<(), Box> { @@ -115,3 +111,7 @@ async fn main() -> std::result::Result<(), Box> { Ok(()) } + +fn get_openai_api_key() -> String { + std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| OPENAI_API_KEY.to_string()) +} diff --git a/src/reloaded-code-serdesai/examples/serdesai-task.rs b/src/reloaded-code-serdesai/examples/serdesai-task.rs index 7a1df460..e8fd2542 100644 --- a/src/reloaded-code-serdesai/examples/serdesai-task.rs +++ b/src/reloaded-code-serdesai/examples/serdesai-task.rs @@ -21,9 +21,21 @@ use std::{ }; const AGENT_NAME: &str = "orchestrator"; -const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; const API_KEY_NAME: &str = "SYNTHETIC_API_KEY"; const API_KEY_VALUE: &str = ""; // <-- Set your API key here +const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; + +struct OpenStreamTag { + message_id: u32, + tag: &'static str, +} + +struct PendingToolCall { + message_id: u32, + tool_name: String, + tool_call_id: Option, + args: String, +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -158,11 +170,17 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn render_user_content(content: &UserContent) -> String { - match content { - UserContent::Text(text) => text.clone(), - UserContent::Parts(_) => serde_json::to_string_pretty(content) - .expect("user content serialization should succeed"), +fn find_pending_tool_call_mut<'a>( + pending: &'a mut [PendingToolCall], + tool_call_id: Option<&str>, +) -> Option<&'a mut PendingToolCall> { + // Most providers include a tool_call_id; fall back to the last pending call otherwise. + match tool_call_id { + Some(tool_call_id) => pending + .iter_mut() + .rev() + .find(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), + None => pending.last_mut(), } } @@ -180,13 +198,37 @@ fn log_xml(message_id: u32, tag: &str, content: &str) { println!("{line}"); } -fn close_stream_xml(open_tag: &mut Option) { - if let Some(tag) = open_tag.take() { - println!(); - println!("", tag.tag); +fn render_tool_input(tool_name: &str, args_text: &str) -> String { + match serde_json::from_str::(args_text) { + Ok(args) if tool_name == "task" => render_task_input(&args), + Ok(args) => { + serde_json::to_string_pretty(&args).expect("tool args serialization should succeed") + } + Err(_) => args_text.to_string(), + } +} + +fn render_user_content(content: &UserContent) -> String { + match content { + UserContent::Text(text) => text.clone(), + UserContent::Parts(_) => serde_json::to_string_pretty(content) + .expect("user content serialization should succeed"), } } +fn take_pending_tool_call( + pending: &mut Vec, + tool_call_id: Option<&str>, +) -> Option { + let index = match tool_call_id { + Some(tool_call_id) => pending + .iter() + .rposition(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), + None => pending.len().checked_sub(1), + }?; + Some(pending.remove(index)) +} + fn write_stream_delta( open_tag: &mut Option, message_id: u32, @@ -211,52 +253,10 @@ fn write_stream_delta( let _ = io::stdout().flush(); } -struct OpenStreamTag { - message_id: u32, - tag: &'static str, -} - -struct PendingToolCall { - message_id: u32, - tool_name: String, - tool_call_id: Option, - args: String, -} - -fn find_pending_tool_call_mut<'a>( - pending: &'a mut [PendingToolCall], - tool_call_id: Option<&str>, -) -> Option<&'a mut PendingToolCall> { - // Most providers include a tool_call_id; fall back to the last pending call otherwise. - match tool_call_id { - Some(tool_call_id) => pending - .iter_mut() - .rev() - .find(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), - None => pending.last_mut(), - } -} - -fn take_pending_tool_call( - pending: &mut Vec, - tool_call_id: Option<&str>, -) -> Option { - let index = match tool_call_id { - Some(tool_call_id) => pending - .iter() - .rposition(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), - None => pending.len().checked_sub(1), - }?; - Some(pending.remove(index)) -} - -fn render_tool_input(tool_name: &str, args_text: &str) -> String { - match serde_json::from_str::(args_text) { - Ok(args) if tool_name == "task" => render_task_input(&args), - Ok(args) => { - serde_json::to_string_pretty(&args).expect("tool args serialization should succeed") - } - Err(_) => args_text.to_string(), +fn close_stream_xml(open_tag: &mut Option) { + if let Some(tag) = open_tag.take() { + println!(); + println!("", tag.tag); } } diff --git a/src/reloaded-code-serdesai/src/agent_ext.rs b/src/reloaded-code-serdesai/src/agent_ext.rs index 31597cc6..ed4ab984 100644 --- a/src/reloaded-code-serdesai/src/agent_ext.rs +++ b/src/reloaded-code-serdesai/src/agent_ext.rs @@ -27,6 +27,11 @@ use serdes_ai::agent::ToolExecutor; use serdes_ai::tools::{RunContext as ToolsRunContext, Tool, ToolError, ToolReturn}; use serdes_ai::{AgentBuilder, RunContext as AgentRunContext}; +/// Adapter for boxed trait object tools, similar to [`ToolAsExecutor`] but +/// for dynamically dispatched tools where the concrete type is not known +/// at compile time. +struct DynToolAsExecutor(Box + Send + Sync>); + /// Adapter that wraps a [`Tool`] to implement [`ToolExecutor`]. /// /// This bridges the gap between `serdes_ai::tools::Tool` (which uses @@ -34,50 +39,6 @@ use serdes_ai::{AgentBuilder, RunContext as AgentRunContext}; /// `agent::RunContext`). struct ToolAsExecutor(T); -#[async_trait] -impl> ToolExecutor for ToolAsExecutor { - async fn execute( - &self, - args: JsonValue, - ctx: &AgentRunContext, - ) -> Result { - // Convert agent::RunContext to tools::RunContext - let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) - .with_run_id(&ctx.run_id) - .with_model_settings(ctx.model_settings.clone()) - .with_tool_context( - ctx.tool_name.as_deref().unwrap_or_default(), - ctx.tool_call_id.clone(), - ); - - self.0.call(&tools_ctx, args).await - } -} - -/// Adapter for boxed trait object tools, similar to [`ToolAsExecutor`] but -/// for dynamically dispatched tools where the concrete type is not known -/// at compile time. -struct DynToolAsExecutor(Box + Send + Sync>); - -#[async_trait] -impl ToolExecutor for DynToolAsExecutor { - async fn execute( - &self, - args: JsonValue, - ctx: &AgentRunContext, - ) -> Result { - let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) - .with_run_id(&ctx.run_id) - .with_model_settings(ctx.model_settings.clone()) - .with_tool_context( - ctx.tool_name.as_deref().unwrap_or_default(), - ctx.tool_call_id.clone(), - ); - - self.0.call(&tools_ctx, args).await - } -} - /// Extension trait for [`AgentBuilder`] to add tools that implement [`Tool`]. pub trait AgentBuilderExt { /// Add a tool that implements the [`Tool`] trait. @@ -113,25 +74,6 @@ pub trait AgentBuilderExt { ) -> Self; } -impl AgentBuilderExt for AgentBuilder -where - Deps: Send + Sync + 'static, - Output: Send + Sync + 'static, -{ - fn tool + 'static>(self, tool: T) -> Self { - let definition = tool.definition(); - self.tool_with_executor(definition, ToolAsExecutor(tool)) - } - - fn tool_dyn( - self, - definition: serdes_ai::ToolDefinition, - tool: Box + Send + Sync>, - ) -> Self { - self.tool_with_executor(definition, DynToolAsExecutor(tool)) - } -} - /// Extension for converting [`ToolError`] results into [`AgentBuildError`]. /// /// This avoids repeating the full `ToolSettingsValidation` struct literal at @@ -157,6 +99,64 @@ pub trait ToolResultExt { fn with_tool(self, tool: &'static str) -> Result; } +#[async_trait] +impl ToolExecutor for DynToolAsExecutor { + async fn execute( + &self, + args: JsonValue, + ctx: &AgentRunContext, + ) -> Result { + let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) + .with_run_id(&ctx.run_id) + .with_model_settings(ctx.model_settings.clone()) + .with_tool_context( + ctx.tool_name.as_deref().unwrap_or_default(), + ctx.tool_call_id.clone(), + ); + + self.0.call(&tools_ctx, args).await + } +} + +#[async_trait] +impl> ToolExecutor for ToolAsExecutor { + async fn execute( + &self, + args: JsonValue, + ctx: &AgentRunContext, + ) -> Result { + // Convert agent::RunContext to tools::RunContext + let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) + .with_run_id(&ctx.run_id) + .with_model_settings(ctx.model_settings.clone()) + .with_tool_context( + ctx.tool_name.as_deref().unwrap_or_default(), + ctx.tool_call_id.clone(), + ); + + self.0.call(&tools_ctx, args).await + } +} + +impl AgentBuilderExt for AgentBuilder +where + Deps: Send + Sync + 'static, + Output: Send + Sync + 'static, +{ + fn tool + 'static>(self, tool: T) -> Self { + let definition = tool.definition(); + self.tool_with_executor(definition, ToolAsExecutor(tool)) + } + + fn tool_dyn( + self, + definition: serdes_ai::ToolDefinition, + tool: Box + Send + Sync>, + ) -> Self { + self.tool_with_executor(definition, DynToolAsExecutor(tool)) + } +} + impl ToolResultExt for Result { /// # Errors /// - Returns [`AgentBuildError::ToolSettingsValidation`] when the original result diff --git a/src/reloaded-code-serdesai/src/agent_runtime/build.rs b/src/reloaded-code-serdesai/src/agent_runtime/build.rs index c71d5448..a769a514 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/build.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/build.rs @@ -18,6 +18,8 @@ use reloaded_code_agents::{ AgentRuntime, AgentToolSettings, ModelResolutionError, PermissionRule, TaskTargetSummary, build_resolver_for_tool, }; +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +use reloaded_code_bubblewrap::Profile; use reloaded_code_core::context::ToolPrompt; use reloaded_code_core::permissions::Ruleset; use reloaded_code_core::tool_context::ToolBuildContext; @@ -37,13 +39,6 @@ use serdes_ai_models::BoxedModel; use std::path::Path; use std::sync::Arc; -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -use reloaded_code_bubblewrap::Profile; - -#[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] -/// Placeholder type so [`attach_standard_tools`] compiles without the feature. -pub(super) struct Profile; - /// Error returned when a build cannot produce a SerdesAI agent. #[derive(Debug, thiserror::Error)] pub enum AgentBuildError { @@ -127,6 +122,10 @@ pub(super) struct PreparedBuild<'a> { permission_config: &'a IndexMap, } +#[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] +/// Placeholder type so [`attach_standard_tools`] compiles without the feature. +pub(super) struct Profile; + impl PreparedBuild<'_> { /// Returns the resolved SerdesAI model for builder construction. #[inline] @@ -135,52 +134,6 @@ impl PreparedBuild<'_> { } } -/// Resolves model configuration and collects build parameters for an agent. -pub(super) fn prepare_build<'a, C>( - runtime: &'a AgentRuntime, - name: &str, - model_catalog: &ModelCatalog, - credentials: &C, - with_summaries: bool, -) -> Result, AgentBuildError> -where - C: CredentialLookup, -{ - let agent = runtime - .catalog() - .by_name(name) - .ok_or_else(|| AgentBuildError::UnknownAgent { name: name.into() })?; - let resolved = resolve_model(model_catalog, runtime.defaults(), agent)?; - let serdes_model = build_serdes_model(model_catalog, &resolved, credentials)?; - let tools = runtime.allowed_tools(name).to_vec(); - let callable_target_summaries = if with_summaries { - runtime.summarize_callable_targets(name).to_vec() - } else { - Vec::new() - }; - - let permission = runtime - .permission_ruleset(name) - .filter(|ruleset| !ruleset.is_empty()); - - Ok(PreparedBuild { - agent_name: agent.name.clone(), - model: serdes_model.model, - model_spec: serdes_model.spec, - prompt: agent.prompt.clone(), - temperature: agent - .temperature - .or(runtime.defaults().temperature) - .map(f64::from), - top_p: agent.top_p.or(runtime.defaults().top_p).map(f64::from), - tools, - tool_settings: agent.tool_settings.clone(), - callable_target_summaries, - permission, - permission_config: &agent.permission, - }) -} - /// Attaches the standard runtime tools and prompt contexts without finalizing the builder. /// /// # Errors @@ -361,14 +314,58 @@ where Ok((builder, prompt_builder)) } -fn build_read_settings( - settings: &reloaded_code_agents::ReadToolSettings, -) -> Result { - ReadSettings::new() - .with_limits(settings.limit, settings.limit) - .and_then(|value| value.with_max_line_length(settings.max_line_length)) - .map(|value| value.with_line_numbers(settings.line_numbers)) - .with_tool(read_meta::NAME) +/// Resolves model configuration and collects build parameters for an agent. +pub(super) fn prepare_build<'a, C>( + runtime: &'a AgentRuntime, + name: &str, + model_catalog: &ModelCatalog, + credentials: &C, + with_summaries: bool, +) -> Result, AgentBuildError> +where + C: CredentialLookup, +{ + let agent = runtime + .catalog() + .by_name(name) + .ok_or_else(|| AgentBuildError::UnknownAgent { name: name.into() })?; + let resolved = resolve_model(model_catalog, runtime.defaults(), agent)?; + let serdes_model = build_serdes_model(model_catalog, &resolved, credentials)?; + let tools = runtime.allowed_tools(name).to_vec(); + let callable_target_summaries = if with_summaries { + runtime.summarize_callable_targets(name).to_vec() + } else { + Vec::new() + }; + + let permission = runtime + .permission_ruleset(name) + .filter(|ruleset| !ruleset.is_empty()); + + Ok(PreparedBuild { + agent_name: agent.name.clone(), + model: serdes_model.model, + model_spec: serdes_model.spec, + prompt: agent.prompt.clone(), + temperature: agent + .temperature + .or(runtime.defaults().temperature) + .map(f64::from), + top_p: agent.top_p.or(runtime.defaults().top_p).map(f64::from), + tools, + tool_settings: agent.tool_settings.clone(), + callable_target_summaries, + permission, + permission_config: &agent.permission, + }) +} + +fn build_glob_settings( + settings: &reloaded_code_agents::GlobToolSettings, +) -> Result { + GlobSettings::new() + .with_limit(settings.limit) + .with_tool(glob_meta::NAME) } fn build_grep_settings( @@ -386,12 +383,14 @@ fn build_grep_settings( Ok((search_settings, formatting_settings)) } -fn build_glob_settings( - settings: &reloaded_code_agents::GlobToolSettings, -) -> Result { - GlobSettings::new() - .with_limit(settings.limit) - .with_tool(glob_meta::NAME) +fn build_read_settings( + settings: &reloaded_code_agents::ReadToolSettings, +) -> Result { + ReadSettings::new() + .with_limits(settings.limit, settings.limit) + .and_then(|value| value.with_max_line_length(settings.max_line_length)) + .map(|value| value.with_line_numbers(settings.line_numbers)) + .with_tool(read_meta::NAME) } fn build_webfetch_settings( diff --git a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs index dd679e25..4d52bd2e 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs @@ -8,13 +8,6 @@ //! - [`AgentBuildContext`] - Shared context that builds runnable agents by name. //! - [`AgentBuildError`] - Build-time failures. -mod build; -mod model; -mod provider_bridge; -mod task; -#[cfg(test)] -mod test_stubs; - pub use build::AgentBuildError; pub use reloaded_code_agents::{ AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, @@ -22,3 +15,10 @@ pub use reloaded_code_agents::{ }; pub use task::AgentBuildContext; pub(crate) use task::{TaskBuildContext, build_agent}; + +mod build; +mod model; +mod provider_bridge; +mod task; +#[cfg(test)] +mod test_stubs; diff --git a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs index 4e87f24f..ec0bcdfe 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs @@ -10,14 +10,14 @@ use reloaded_code_core::{ use serdes_ai_models::{BoxedModel, Model as SerdesModel, ModelError}; use std::sync::Arc; -const COHERE_BASE_URL: &str = "https://api.cohere.ai/v2"; -const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; -const OPENAI_COMPATIBLE_PROVIDER: &str = "openai"; const AWS_ACCESS_KEY_ID_ENV_VAR: &str = "AWS_ACCESS_KEY_ID"; +const AWS_DEFAULT_REGION_ENV_VAR: &str = "AWS_DEFAULT_REGION"; +const AWS_REGION_ENV_VAR: &str = "AWS_REGION"; const AWS_SECRET_ACCESS_KEY_ENV_VAR: &str = "AWS_SECRET_ACCESS_KEY"; const AWS_SESSION_TOKEN_ENV_VAR: &str = "AWS_SESSION_TOKEN"; -const AWS_REGION_ENV_VAR: &str = "AWS_REGION"; -const AWS_DEFAULT_REGION_ENV_VAR: &str = "AWS_DEFAULT_REGION"; +const COHERE_BASE_URL: &str = "https://api.cohere.ai/v2"; +const OPENAI_COMPATIBLE_PROVIDER: &str = "openai"; +const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; /// Concrete SerdesAI model prepared from catalog metadata. #[derive(Clone)] @@ -45,139 +45,6 @@ impl ResolvedSerdesModel { } } -/// Normalizes an API URL from the catalog by trimming whitespace and trailing slashes. -/// -/// Returns `None` if the result is empty, allowing callers to treat missing/empty URLs -/// uniformly as "use the provider's default endpoint". -#[inline] -fn normalized_api_url(api_url: &str) -> Option<&str> { - let trimmed = api_url.trim().trim_end_matches('/'); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } -} - -/// Checks if an environment variable name represents an authentication credential. -/// -/// Providers list environment variable names in their catalog entry. This predicate -/// identifies which ones contain secrets like API keys or tokens, used to extract -/// credentials for model construction. -#[inline] -fn is_credential_env_var(env_var: &str) -> bool { - env_var.ends_with("_API_KEY") - || env_var.ends_with("_ACCESS_TOKEN") - || env_var.ends_with("_TOKEN") -} - -/// Finds the first environment variable matching a predicate that has a non-empty value. -/// -/// The catalog lists possible environment variable names for a provider. This function -/// searches through them in order and returns the resolved value of the first one that -/// both matches the predicate and is actually set. -fn first_matching_env_value

( - credentials: &impl CredentialLookup, - env_vars: &[&str], - mut predicate: P, -) -> Option -where - P: FnMut(&str) -> bool, -{ - env_vars.iter().copied().find_map(|env_var| { - if !predicate(env_var) { - return None; - } - credentials.resolve(env_var) - }) -} - -/// Formats a comma-separated list of environment variable names matching a predicate. -/// -/// Used in error messages to tell users which environment variables they can set -/// to provide a required value (credential, endpoint, etc.). -/// -/// Preallocates 64 bytes, enough for ~3 typical env var names (e.g., `OPENROUTER_API_KEY`). -fn matching_env_names

(env_vars: &[&str], mut predicate: P) -> String -where - P: FnMut(&str) -> bool, -{ - let mut names = String::with_capacity(64); - for env_var in env_vars - .iter() - .copied() - .filter(|env_var| predicate(env_var)) - { - if !names.is_empty() { - names.push_str(", "); - } - names.push_str(env_var); - } - if names.is_empty() { - names.push_str(""); - } - names -} - -/// Finds the first resolved value among explicit credential names. -fn first_resolved_name(credentials: &impl CredentialLookup, names: &[&str]) -> Option { - names - .iter() - .copied() - .find_map(|name| credentials.resolve(name)) -} - -/// Requires an environment variable matching a predicate to have a value. -/// -/// Returns the resolved value if found, otherwise returns a configuration error -/// that lists the available environment variable names for user guidance. -fn require_env_value

( - credentials: &impl CredentialLookup, - provider_key: &str, - provider_name: &str, - env_vars: &[&str], - kind: &str, - predicate: P, -) -> Result -where - P: Copy + Fn(&str) -> bool, -{ - if let Some(value) = first_matching_env_value(credentials, env_vars, predicate) { - return Ok(value); - } - - Err(ModelError::configuration(format!( - "provider `{provider_key}` mapped to serdes `{provider_name}` requires {kind}; set one of: {}", - matching_env_names(env_vars, predicate) - ))) -} - -/// Requires a specific named credential to have a value. -fn require_named_value( - credentials: &impl CredentialLookup, - provider_key: &str, - provider_name: &str, - name: &str, - kind: &str, -) -> Result { - if let Some(value) = credentials.resolve(name) { - return Ok(value); - } - - Err(ModelError::configuration(format!( - "provider `{provider_key}` mapped to serdes `{provider_name}` requires {kind}; set `{name}`" - ))) -} - -/// Creates an error for a provider whose feature flag is disabled at compile time. -#[allow(dead_code)] -#[inline] -fn feature_disabled_error(feature: &str, provider_name: &str) -> ModelError { - ModelError::configuration(format!( - "provider `{provider_name}` is not enabled in reloaded-code-serdesai; rebuild with `--features {feature}`" - )) -} - /// Builds the concrete SerdesAI model for a validated runtime model selection. pub(super) fn build_serdes_model( catalog: &ModelCatalog, @@ -309,189 +176,320 @@ pub(super) fn build_serdes_model( } // ============================================================================= -// OpenAI (Chat and Responses) +// Anthropic // ============================================================================= -fn build_openai_chat( +fn build_anthropic( provider_key: &str, model_name: &str, api_url: Option<&str>, env_vars: &[&str], credentials: &impl CredentialLookup, ) -> Result { - #[cfg(feature = "openai")] + #[cfg(feature = "anthropic")] { - // When the provider lists credential env vars, require at least one to be set. - // When no credential env vars are listed (e.g., local OpenAI-compatible - // endpoints like Ollama behind a compat layer), proceed with an empty key. - let has_credential_vars = env_vars.iter().any(|v| is_credential_env_var(v)); - let api_key = if has_credential_vars { - // Credential env vars listed - require one to be set. - require_env_value( - credentials, - provider_key, - OPENAI_COMPATIBLE_PROVIDER, - env_vars, - "a credential", - is_credential_env_var, - )? - } else { - // No credential env vars - allow keyless endpoint (e.g., local Ollama). - String::new() - }; - let mut model = serdes_ai_models::OpenAIChatModel::new(model_name, api_key); + let api_key = require_env_value( + credentials, + provider_key, + "anthropic", + env_vars, + "an API key", + is_credential_env_var, + )?; + let mut model = serdes_ai_models::AnthropicModel::new(model_name, api_key); if let Some(api_url) = api_url { model = model.with_base_url(api_url); } - Ok(ResolvedSerdesModel::new( - OPENAI_COMPATIBLE_PROVIDER, - model_name, - model, - )) + Ok(ResolvedSerdesModel::new("anthropic", model_name, model)) } - #[cfg(not(feature = "openai"))] + #[cfg(not(feature = "anthropic"))] { let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("openai", OPENAI_COMPATIBLE_PROVIDER)) + Err(feature_disabled_error("anthropic", "anthropic")) } } -fn build_openai_responses( +fn build_antigravity( provider_key: &str, model_name: &str, api_url: Option<&str>, env_vars: &[&str], credentials: &impl CredentialLookup, ) -> Result { - #[cfg(feature = "openai")] + #[cfg(feature = "antigravity")] { - let api_key = require_env_value( + let access_token = require_env_value( credentials, provider_key, - OPENAI_COMPATIBLE_PROVIDER, + "antigravity", env_vars, - "a credential", + "an access token", is_credential_env_var, )?; - let mut model = serdes_ai_models::OpenAIResponsesModel::new(model_name, api_key); + let project_id = + first_matching_env_value(credentials, env_vars, is_antigravity_project_id_env_var) + .unwrap_or_else(|| serdes_ai_models::antigravity::DEFAULT_PROJECT_ID.to_owned()); + let mut model = + serdes_ai_models::AntigravityModel::new(model_name, access_token, project_id); if let Some(api_url) = api_url { - model = model.with_base_url(api_url); + model = model.with_config(serdes_ai_models::antigravity::AntigravityConfig { + endpoint: api_url.to_owned(), + ..serdes_ai_models::antigravity::AntigravityConfig::default() + }); } - Ok(ResolvedSerdesModel::new( - OPENAI_COMPATIBLE_PROVIDER, - model_name, - model, - )) + Ok(ResolvedSerdesModel::new("antigravity", model_name, model)) } - #[cfg(not(feature = "openai"))] + #[cfg(not(feature = "antigravity"))] { let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("openai", OPENAI_COMPATIBLE_PROVIDER)) + Err(feature_disabled_error("antigravity", "antigravity")) } } -// ============================================================================= -// Anthropic -// ============================================================================= - -fn build_anthropic( +fn build_azure( provider_key: &str, model_name: &str, api_url: Option<&str>, env_vars: &[&str], credentials: &impl CredentialLookup, ) -> Result { - #[cfg(feature = "anthropic")] + #[cfg(feature = "azure")] { + let endpoint = resolve_azure_endpoint(credentials, provider_key, api_url, env_vars)?; let api_key = require_env_value( credentials, provider_key, - "anthropic", + "azure", env_vars, "an API key", is_credential_env_var, )?; - let mut model = serdes_ai_models::AnthropicModel::new(model_name, api_key); - if let Some(api_url) = api_url { - model = model.with_base_url(api_url); - } - Ok(ResolvedSerdesModel::new("anthropic", model_name, model)) + Ok(ResolvedSerdesModel::new( + "azure", + model_name, + serdes_ai_models::AzureOpenAIModel::new( + model_name, + endpoint, + serdes_ai_models::AzureOpenAIModel::DEFAULT_API_VERSION, + api_key, + ), + )) } - #[cfg(not(feature = "anthropic"))] + #[cfg(not(feature = "azure"))] { let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("anthropic", "anthropic")) + Err(feature_disabled_error("azure", "azure")) } } // ============================================================================= -// Google +// Bedrock // ============================================================================= -fn build_google( - provider_key: &str, - model_name: &str, - api_url: Option<&str>, - env_vars: &[&str], - credentials: &impl CredentialLookup, +/// Build a Bedrock model. +/// +/// Bedrock resolves the standard AWS credential names through [`CredentialLookup`] and passes +/// them into the SerdesAI model constructor. Region remains optional and falls back to the model +/// default when neither `AWS_REGION` nor `AWS_DEFAULT_REGION` is provided. +fn build_bedrock( + provider_key: &str, + model_name: &str, + api_url: Option<&str>, + env_vars: &[&str], + credentials: &impl CredentialLookup, ) -> Result { - #[cfg(any(feature = "google", feature = "gemini"))] + #[cfg(feature = "bedrock")] { - let api_key = require_env_value( + let _ = (api_url, env_vars); + let access_key_id = require_named_value( credentials, provider_key, - "google", + "bedrock", + AWS_ACCESS_KEY_ID_ENV_VAR, + "an AWS access key ID", + )?; + let secret_access_key = require_named_value( + credentials, + provider_key, + "bedrock", + AWS_SECRET_ACCESS_KEY_ENV_VAR, + "an AWS secret access key", + )?; + let mut aws_credentials = + serdes_ai_models::bedrock::AwsCredentials::new(access_key_id, secret_access_key); + if let Some(session_token) = credentials.resolve(AWS_SESSION_TOKEN_ENV_VAR) { + aws_credentials = aws_credentials.with_session_token(session_token); + } + + let mut model = + serdes_ai_models::BedrockModel::with_credentials(model_name, aws_credentials); + if let Some(region) = first_resolved_name( + credentials, + &[AWS_REGION_ENV_VAR, AWS_DEFAULT_REGION_ENV_VAR], + ) { + model = model.with_region(region); + } + + Ok(ResolvedSerdesModel::new("bedrock", model_name, model)) + } + #[cfg(not(feature = "bedrock"))] + { + let _ = (provider_key, model_name, api_url, env_vars, credentials); + Err(feature_disabled_error("bedrock", "bedrock")) + } +} + +fn build_chatgpt_oauth( + provider_key: &str, + model_name: &str, + api_url: Option<&str>, + env_vars: &[&str], + credentials: &impl CredentialLookup, +) -> Result { + #[cfg(feature = "chatgpt-oauth")] + { + let access_token = require_env_value( + credentials, + provider_key, + "chatgpt-oauth", env_vars, - "an API key", + "an access token", is_credential_env_var, )?; - let mut model = serdes_ai_models::google::GoogleModel::new(model_name, api_key); + let mut model = serdes_ai_models::ChatGptOAuthModel::new(model_name, access_token); if let Some(api_url) = api_url { - model = model.with_base_url(api_url); + model = model.with_config(serdes_ai_models::chatgpt_oauth::ChatGptConfig { + api_base_url: api_url.to_owned(), + ..serdes_ai_models::chatgpt_oauth::ChatGptConfig::default() + }); } - Ok(ResolvedSerdesModel::new("google", model_name, model)) + if let Some(account_id) = + first_matching_env_value(credentials, env_vars, is_chatgpt_oauth_account_id_env_var) + { + model = model.with_account_id(account_id); + } + Ok(ResolvedSerdesModel::new("chatgpt-oauth", model_name, model)) } - #[cfg(not(any(feature = "google", feature = "gemini")))] + #[cfg(not(feature = "chatgpt-oauth"))] { let _ = (provider_key, model_name, api_url, env_vars); - Err(ModelError::configuration( - "provider `google` is not enabled in reloaded-code-serdesai; rebuild with `--features google` or `--features gemini`", + Err(feature_disabled_error("chatgpt-oauth", "chatgpt-oauth")) + } +} + +// ============================================================================= +// Claude Code OAuth +// ============================================================================= + +fn build_claude_code_oauth( + provider_key: &str, + model_name: &str, + api_url: Option<&str>, + env_vars: &[&str], + credentials: &impl CredentialLookup, +) -> Result { + #[cfg(feature = "claude-code-oauth")] + { + let access_token = require_env_value( + credentials, + provider_key, + "claude-code-oauth", + env_vars, + "an access token", + is_credential_env_var, + )?; + let mut model = serdes_ai_models::ClaudeCodeOAuthModel::new(model_name, access_token); + if let Some(api_url) = api_url { + model = model.with_config(serdes_ai_models::claude_code_oauth::ClaudeCodeConfig { + api_base_url: api_url.to_owned(), + ..serdes_ai_models::claude_code_oauth::ClaudeCodeConfig::default() + }); + } + Ok(ResolvedSerdesModel::new( + "claude-code-oauth", + model_name, + model, + )) + } + #[cfg(not(feature = "claude-code-oauth"))] + { + let _ = (provider_key, model_name, api_url, env_vars); + Err(feature_disabled_error( + "claude-code-oauth", + "claude-code-oauth", )) } } // ============================================================================= -// Groq (fixed endpoint - no URL override allowed) +// Cohere (fixed endpoint - no URL override allowed) // ============================================================================= -/// Compares two URLs for equality, ignoring trailing slashes. -/// -/// URLs often include or omit trailing slashes inconsistently, but represent the same -/// endpoint. This normalizes both sides before comparison. -#[inline] -fn urls_equal_ignoring_slash(lhs: &str, rhs: &str) -> bool { - lhs.trim_end_matches('/') == rhs.trim_end_matches('/') +fn build_cohere( + provider_key: &str, + model_name: &str, + api_url: Option<&str>, + env_vars: &[&str], + credentials: &impl CredentialLookup, +) -> Result { + #[cfg(feature = "cohere")] + { + validate_fixed_endpoint(provider_key, "cohere", api_url, COHERE_BASE_URL)?; + let api_key = require_env_value( + credentials, + provider_key, + "cohere", + env_vars, + "an API key", + is_credential_env_var, + )?; + Ok(ResolvedSerdesModel::new( + "cohere", + model_name, + serdes_ai_models::CohereModel::new(model_name, api_key), + )) + } + #[cfg(not(feature = "cohere"))] + { + let _ = (provider_key, model_name, api_url, env_vars); + Err(feature_disabled_error("cohere", "cohere")) + } } -/// Validates that a provider with a fixed endpoint isn't configured with a different URL. -/// -/// Some providers (Groq, Cohere, OpenRouter) have hardcoded base URLs in their model -/// implementations and don't support custom endpoints. If the catalog specifies a URL -/// that differs from the expected one, this returns a configuration error. -fn validate_fixed_endpoint( +// ============================================================================= +// Google +// ============================================================================= + +fn build_google( provider_key: &str, - provider_name: &str, + model_name: &str, api_url: Option<&str>, - expected_url: &str, -) -> Result<(), ModelError> { - if let Some(api_url) = api_url - && !urls_equal_ignoring_slash(api_url, expected_url) + env_vars: &[&str], + credentials: &impl CredentialLookup, +) -> Result { + #[cfg(any(feature = "google", feature = "gemini"))] { - return Err(ModelError::configuration(format!( - "provider `{provider_key}` mapped to serdes `{provider_name}` uses catalog api url `{api_url}`, but the SerdesAI `{provider_name}` model does not support overriding its built-in endpoint `{expected_url}`" - ))); + let api_key = require_env_value( + credentials, + provider_key, + "google", + env_vars, + "an API key", + is_credential_env_var, + )?; + let mut model = serdes_ai_models::google::GoogleModel::new(model_name, api_key); + if let Some(api_url) = api_url { + model = model.with_base_url(api_url); + } + Ok(ResolvedSerdesModel::new("google", model_name, model)) + } + #[cfg(not(any(feature = "google", feature = "gemini")))] + { + let _ = (provider_key, model_name, api_url, env_vars); + Err(ModelError::configuration( + "provider `google` is not enabled in reloaded-code-serdesai; rebuild with `--features google` or `--features gemini`", + )) } - Ok(()) } fn build_groq( @@ -530,6 +528,40 @@ fn build_groq( } } +// ============================================================================= +// HuggingFace +// ============================================================================= + +fn build_huggingface( + provider_key: &str, + model_name: &str, + api_url: Option<&str>, + env_vars: &[&str], + credentials: &impl CredentialLookup, +) -> Result { + #[cfg(feature = "huggingface")] + { + let token = require_env_value( + credentials, + provider_key, + "huggingface", + env_vars, + "a token", + is_credential_env_var, + )?; + let mut model = serdes_ai_models::HuggingFaceModel::new(model_name, token); + if let Some(api_url) = api_url { + model = model.with_endpoint(api_url); + } + Ok(ResolvedSerdesModel::new("huggingface", model_name, model)) + } + #[cfg(not(feature = "huggingface"))] + { + let _ = (provider_key, model_name, api_url, env_vars); + Err(feature_disabled_error("huggingface", "huggingface")) + } +} + // ============================================================================= // Mistral // ============================================================================= @@ -592,173 +624,84 @@ fn build_ollama( } // ============================================================================= -// Bedrock +// OpenAI (Chat and Responses) // ============================================================================= -/// Build a Bedrock model. -/// -/// Bedrock resolves the standard AWS credential names through [`CredentialLookup`] and passes -/// them into the SerdesAI model constructor. Region remains optional and falls back to the model -/// default when neither `AWS_REGION` nor `AWS_DEFAULT_REGION` is provided. -fn build_bedrock( +fn build_openai_chat( provider_key: &str, model_name: &str, api_url: Option<&str>, env_vars: &[&str], credentials: &impl CredentialLookup, ) -> Result { - #[cfg(feature = "bedrock")] + #[cfg(feature = "openai")] { - let _ = (api_url, env_vars); - let access_key_id = require_named_value( - credentials, - provider_key, - "bedrock", - AWS_ACCESS_KEY_ID_ENV_VAR, - "an AWS access key ID", - )?; - let secret_access_key = require_named_value( - credentials, - provider_key, - "bedrock", - AWS_SECRET_ACCESS_KEY_ENV_VAR, - "an AWS secret access key", - )?; - let mut aws_credentials = - serdes_ai_models::bedrock::AwsCredentials::new(access_key_id, secret_access_key); - if let Some(session_token) = credentials.resolve(AWS_SESSION_TOKEN_ENV_VAR) { - aws_credentials = aws_credentials.with_session_token(session_token); - } - - let mut model = - serdes_ai_models::BedrockModel::with_credentials(model_name, aws_credentials); - if let Some(region) = first_resolved_name( - credentials, - &[AWS_REGION_ENV_VAR, AWS_DEFAULT_REGION_ENV_VAR], - ) { - model = model.with_region(region); + // When the provider lists credential env vars, require at least one to be set. + // When no credential env vars are listed (e.g., local OpenAI-compatible + // endpoints like Ollama behind a compat layer), proceed with an empty key. + let has_credential_vars = env_vars.iter().any(|v| is_credential_env_var(v)); + let api_key = if has_credential_vars { + // Credential env vars listed - require one to be set. + require_env_value( + credentials, + provider_key, + OPENAI_COMPATIBLE_PROVIDER, + env_vars, + "a credential", + is_credential_env_var, + )? + } else { + // No credential env vars - allow keyless endpoint (e.g., local Ollama). + String::new() + }; + let mut model = serdes_ai_models::OpenAIChatModel::new(model_name, api_key); + if let Some(api_url) = api_url { + model = model.with_base_url(api_url); } - - Ok(ResolvedSerdesModel::new("bedrock", model_name, model)) - } - #[cfg(not(feature = "bedrock"))] - { - let _ = (provider_key, model_name, api_url, env_vars, credentials); - Err(feature_disabled_error("bedrock", "bedrock")) - } -} - -// ============================================================================= -// Azure OpenAI -// ============================================================================= - -/// Checks if an environment variable name represents an Azure resource name. -/// -/// Azure OpenAI can be identified by either a full endpoint URL or just the resource -/// name (e.g., "my-resource" becomes `https://my-resource.openai.azure.com`). -/// This identifies catalog env vars that contain resource names rather than full URLs. -#[inline] -fn is_azure_resource_name_env_var(env_var: &str) -> bool { - env_var.ends_with("_RESOURCE_NAME") -} - -/// Normalizes an Azure endpoint URL by removing common redundant path suffixes. -/// -/// Users may copy endpoints from the Azure portal that include `/openai` or `/openai/v1` -/// suffixes, but the Azure SDK constructs the full path internally as -/// `{endpoint}/openai/deployments/{deployment}`. This function strips those suffixes -/// to prevent double paths like `/openai/openai/deployments/...`. -fn normalize_azure_endpoint(endpoint: &str) -> String { - let trimmed = endpoint.trim().trim_end_matches('/'); - if let Some(stripped) = trimmed.strip_suffix("/openai/v1") { - stripped.to_owned() - } else if let Some(stripped) = trimmed.strip_suffix("/openai") { - stripped.to_owned() - } else { - trimmed.to_owned() - } -} - -/// Constructs a full Azure endpoint URL from a resource name. -/// -/// If the input is already a full URL (starts with http:// or https://), it's normalized. -/// Otherwise, the resource name is converted to the standard Azure format: -/// `https://{resource_name}.openai.azure.com` -fn azure_endpoint_from_resource(resource_name: &str) -> String { - let trimmed = resource_name.trim().trim_end_matches('/'); - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - return normalize_azure_endpoint(trimmed); - } - - let mut endpoint = String::with_capacity(trimmed.len() + 27); - endpoint.push_str("https://"); - endpoint.push_str(trimmed); - endpoint.push_str(".openai.azure.com"); - endpoint -} - -/// Resolves the Azure endpoint from catalog configuration or environment variables. -/// -/// Priority: -/// 1. Explicit `api_url` from catalog (normalized) -/// 2. `*_RESOURCE_NAME` environment variable (converted to full URL) -/// -/// Returns an error if neither is available. -fn resolve_azure_endpoint( - credentials: &impl CredentialLookup, - provider_key: &str, - api_url: Option<&str>, - env_vars: &[&str], -) -> Result { - if let Some(api_url) = api_url { - return Ok(normalize_azure_endpoint(api_url)); + Ok(ResolvedSerdesModel::new( + OPENAI_COMPATIBLE_PROVIDER, + model_name, + model, + )) } - - if let Some(resource_name) = - first_matching_env_value(credentials, env_vars, is_azure_resource_name_env_var) + #[cfg(not(feature = "openai"))] { - return Ok(azure_endpoint_from_resource(&resource_name)); + let _ = (provider_key, model_name, api_url, env_vars); + Err(feature_disabled_error("openai", OPENAI_COMPATIBLE_PROVIDER)) } - - Err(ModelError::configuration(format!( - "provider `{provider_key}` mapped to serdes `azure` requires an Azure endpoint or resource name; set one of: {}", - matching_env_names(env_vars, is_azure_resource_name_env_var) - ))) } -fn build_azure( +fn build_openai_responses( provider_key: &str, model_name: &str, api_url: Option<&str>, env_vars: &[&str], credentials: &impl CredentialLookup, ) -> Result { - #[cfg(feature = "azure")] + #[cfg(feature = "openai")] { - let endpoint = resolve_azure_endpoint(credentials, provider_key, api_url, env_vars)?; let api_key = require_env_value( credentials, provider_key, - "azure", + OPENAI_COMPATIBLE_PROVIDER, env_vars, - "an API key", + "a credential", is_credential_env_var, )?; + let mut model = serdes_ai_models::OpenAIResponsesModel::new(model_name, api_key); + if let Some(api_url) = api_url { + model = model.with_base_url(api_url); + } Ok(ResolvedSerdesModel::new( - "azure", + OPENAI_COMPATIBLE_PROVIDER, model_name, - serdes_ai_models::AzureOpenAIModel::new( - model_name, - endpoint, - serdes_ai_models::AzureOpenAIModel::DEFAULT_API_VERSION, - api_key, - ), + model, )) } - #[cfg(not(feature = "azure"))] + #[cfg(not(feature = "openai"))] { let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("azure", "azure")) + Err(feature_disabled_error("openai", OPENAI_COMPATIBLE_PROVIDER)) } } @@ -797,73 +740,75 @@ fn build_openrouter( } } -// ============================================================================= -// HuggingFace -// ============================================================================= - -fn build_huggingface( - provider_key: &str, - model_name: &str, - api_url: Option<&str>, - env_vars: &[&str], - credentials: &impl CredentialLookup, -) -> Result { - #[cfg(feature = "huggingface")] +/// Formats a comma-separated list of environment variable names matching a predicate. +/// +/// Used in error messages to tell users which environment variables they can set +/// to provide a required value (credential, endpoint, etc.). +/// +/// Preallocates 64 bytes, enough for ~3 typical env var names (e.g., `OPENROUTER_API_KEY`). +fn matching_env_names

(env_vars: &[&str], mut predicate: P) -> String +where + P: FnMut(&str) -> bool, +{ + let mut names = String::with_capacity(64); + for env_var in env_vars + .iter() + .copied() + .filter(|env_var| predicate(env_var)) { - let token = require_env_value( - credentials, - provider_key, - "huggingface", - env_vars, - "a token", - is_credential_env_var, - )?; - let mut model = serdes_ai_models::HuggingFaceModel::new(model_name, token); - if let Some(api_url) = api_url { - model = model.with_endpoint(api_url); + if !names.is_empty() { + names.push_str(", "); } - Ok(ResolvedSerdesModel::new("huggingface", model_name, model)) + names.push_str(env_var); } - #[cfg(not(feature = "huggingface"))] - { - let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("huggingface", "huggingface")) + if names.is_empty() { + names.push_str(""); + } + names +} + +/// Normalizes an API URL from the catalog by trimming whitespace and trailing slashes. +/// +/// Returns `None` if the result is empty, allowing callers to treat missing/empty URLs +/// uniformly as "use the provider's default endpoint". +#[inline] +fn normalized_api_url(api_url: &str) -> Option<&str> { + let trimmed = api_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + None + } else { + Some(trimmed) } } +/// Creates an error for a provider whose feature flag is disabled at compile time. +#[allow(dead_code)] +#[inline] +fn feature_disabled_error(feature: &str, provider_name: &str) -> ModelError { + ModelError::configuration(format!( + "provider `{provider_name}` is not enabled in reloaded-code-serdesai; rebuild with `--features {feature}`" + )) +} + +/// Finds the first resolved value among explicit credential names. +fn first_resolved_name(credentials: &impl CredentialLookup, names: &[&str]) -> Option { + names + .iter() + .copied() + .find_map(|name| credentials.resolve(name)) +} + // ============================================================================= -// Cohere (fixed endpoint - no URL override allowed) +// Antigravity // ============================================================================= -fn build_cohere( - provider_key: &str, - model_name: &str, - api_url: Option<&str>, - env_vars: &[&str], - credentials: &impl CredentialLookup, -) -> Result { - #[cfg(feature = "cohere")] - { - validate_fixed_endpoint(provider_key, "cohere", api_url, COHERE_BASE_URL)?; - let api_key = require_env_value( - credentials, - provider_key, - "cohere", - env_vars, - "an API key", - is_credential_env_var, - )?; - Ok(ResolvedSerdesModel::new( - "cohere", - model_name, - serdes_ai_models::CohereModel::new(model_name, api_key), - )) - } - #[cfg(not(feature = "cohere"))] - { - let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("cohere", "cohere")) - } +/// Checks if an environment variable name represents an Antigravity project ID. +/// +/// Antigravity organizes resources into projects; this identifies env vars containing +/// the project ID for scoping API requests. Falls back to a default if not provided. +#[inline] +fn is_antigravity_project_id_env_var(env_var: &str) -> bool { + env_var.ends_with("_PROJECT_ID") } // ============================================================================= @@ -879,135 +824,190 @@ fn is_chatgpt_oauth_account_id_env_var(env_var: &str) -> bool { env_var.ends_with("_ACCOUNT_ID") } -fn build_chatgpt_oauth( +/// Checks if an environment variable name represents an authentication credential. +/// +/// Providers list environment variable names in their catalog entry. This predicate +/// identifies which ones contain secrets like API keys or tokens, used to extract +/// credentials for model construction. +#[inline] +fn is_credential_env_var(env_var: &str) -> bool { + env_var.ends_with("_API_KEY") + || env_var.ends_with("_ACCESS_TOKEN") + || env_var.ends_with("_TOKEN") +} + +/// Requires an environment variable matching a predicate to have a value. +/// +/// Returns the resolved value if found, otherwise returns a configuration error +/// that lists the available environment variable names for user guidance. +fn require_env_value

( + credentials: &impl CredentialLookup, provider_key: &str, - model_name: &str, - api_url: Option<&str>, + provider_name: &str, env_vars: &[&str], - credentials: &impl CredentialLookup, -) -> Result { - #[cfg(feature = "chatgpt-oauth")] - { - let access_token = require_env_value( - credentials, - provider_key, - "chatgpt-oauth", - env_vars, - "an access token", - is_credential_env_var, - )?; - let mut model = serdes_ai_models::ChatGptOAuthModel::new(model_name, access_token); - if let Some(api_url) = api_url { - model = model.with_config(serdes_ai_models::chatgpt_oauth::ChatGptConfig { - api_base_url: api_url.to_owned(), - ..serdes_ai_models::chatgpt_oauth::ChatGptConfig::default() - }); - } - if let Some(account_id) = - first_matching_env_value(credentials, env_vars, is_chatgpt_oauth_account_id_env_var) - { - model = model.with_account_id(account_id); - } - Ok(ResolvedSerdesModel::new("chatgpt-oauth", model_name, model)) - } - #[cfg(not(feature = "chatgpt-oauth"))] - { - let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("chatgpt-oauth", "chatgpt-oauth")) + kind: &str, + predicate: P, +) -> Result +where + P: Copy + Fn(&str) -> bool, +{ + if let Some(value) = first_matching_env_value(credentials, env_vars, predicate) { + return Ok(value); } + + Err(ModelError::configuration(format!( + "provider `{provider_key}` mapped to serdes `{provider_name}` requires {kind}; set one of: {}", + matching_env_names(env_vars, predicate) + ))) } -// ============================================================================= -// Claude Code OAuth -// ============================================================================= +/// Requires a specific named credential to have a value. +fn require_named_value( + credentials: &impl CredentialLookup, + provider_key: &str, + provider_name: &str, + name: &str, + kind: &str, +) -> Result { + if let Some(value) = credentials.resolve(name) { + return Ok(value); + } -fn build_claude_code_oauth( + Err(ModelError::configuration(format!( + "provider `{provider_key}` mapped to serdes `{provider_name}` requires {kind}; set `{name}`" + ))) +} + +/// Resolves the Azure endpoint from catalog configuration or environment variables. +/// +/// Priority: +/// 1. Explicit `api_url` from catalog (normalized) +/// 2. `*_RESOURCE_NAME` environment variable (converted to full URL) +/// +/// Returns an error if neither is available. +fn resolve_azure_endpoint( + credentials: &impl CredentialLookup, provider_key: &str, - model_name: &str, api_url: Option<&str>, env_vars: &[&str], - credentials: &impl CredentialLookup, -) -> Result { - #[cfg(feature = "claude-code-oauth")] +) -> Result { + if let Some(api_url) = api_url { + return Ok(normalize_azure_endpoint(api_url)); + } + + if let Some(resource_name) = + first_matching_env_value(credentials, env_vars, is_azure_resource_name_env_var) { - let access_token = require_env_value( - credentials, - provider_key, - "claude-code-oauth", - env_vars, - "an access token", - is_credential_env_var, - )?; - let mut model = serdes_ai_models::ClaudeCodeOAuthModel::new(model_name, access_token); - if let Some(api_url) = api_url { - model = model.with_config(serdes_ai_models::claude_code_oauth::ClaudeCodeConfig { - api_base_url: api_url.to_owned(), - ..serdes_ai_models::claude_code_oauth::ClaudeCodeConfig::default() - }); - } - Ok(ResolvedSerdesModel::new( - "claude-code-oauth", - model_name, - model, - )) + return Ok(azure_endpoint_from_resource(&resource_name)); } - #[cfg(not(feature = "claude-code-oauth"))] + + Err(ModelError::configuration(format!( + "provider `{provider_key}` mapped to serdes `azure` requires an Azure endpoint or resource name; set one of: {}", + matching_env_names(env_vars, is_azure_resource_name_env_var) + ))) +} + +/// Validates that a provider with a fixed endpoint isn't configured with a different URL. +/// +/// Some providers (Groq, Cohere, OpenRouter) have hardcoded base URLs in their model +/// implementations and don't support custom endpoints. If the catalog specifies a URL +/// that differs from the expected one, this returns a configuration error. +fn validate_fixed_endpoint( + provider_key: &str, + provider_name: &str, + api_url: Option<&str>, + expected_url: &str, +) -> Result<(), ModelError> { + if let Some(api_url) = api_url + && !urls_equal_ignoring_slash(api_url, expected_url) { - let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error( - "claude-code-oauth", - "claude-code-oauth", - )) + return Err(ModelError::configuration(format!( + "provider `{provider_key}` mapped to serdes `{provider_name}` uses catalog api url `{api_url}`, but the SerdesAI `{provider_name}` model does not support overriding its built-in endpoint `{expected_url}`" + ))); } + Ok(()) +} + +/// Constructs a full Azure endpoint URL from a resource name. +/// +/// If the input is already a full URL (starts with http:// or https://), it's normalized. +/// Otherwise, the resource name is converted to the standard Azure format: +/// `https://{resource_name}.openai.azure.com` +fn azure_endpoint_from_resource(resource_name: &str) -> String { + let trimmed = resource_name.trim().trim_end_matches('/'); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return normalize_azure_endpoint(trimmed); + } + + let mut endpoint = String::with_capacity(trimmed.len() + 27); + endpoint.push_str("https://"); + endpoint.push_str(trimmed); + endpoint.push_str(".openai.azure.com"); + endpoint +} + +/// Finds the first environment variable matching a predicate that has a non-empty value. +/// +/// The catalog lists possible environment variable names for a provider. This function +/// searches through them in order and returns the resolved value of the first one that +/// both matches the predicate and is actually set. +fn first_matching_env_value

( + credentials: &impl CredentialLookup, + env_vars: &[&str], + mut predicate: P, +) -> Option +where + P: FnMut(&str) -> bool, +{ + env_vars.iter().copied().find_map(|env_var| { + if !predicate(env_var) { + return None; + } + credentials.resolve(env_var) + }) } // ============================================================================= -// Antigravity +// Azure OpenAI // ============================================================================= -/// Checks if an environment variable name represents an Antigravity project ID. +/// Checks if an environment variable name represents an Azure resource name. /// -/// Antigravity organizes resources into projects; this identifies env vars containing -/// the project ID for scoping API requests. Falls back to a default if not provided. +/// Azure OpenAI can be identified by either a full endpoint URL or just the resource +/// name (e.g., "my-resource" becomes `https://my-resource.openai.azure.com`). +/// This identifies catalog env vars that contain resource names rather than full URLs. #[inline] -fn is_antigravity_project_id_env_var(env_var: &str) -> bool { - env_var.ends_with("_PROJECT_ID") +fn is_azure_resource_name_env_var(env_var: &str) -> bool { + env_var.ends_with("_RESOURCE_NAME") } -fn build_antigravity( - provider_key: &str, - model_name: &str, - api_url: Option<&str>, - env_vars: &[&str], - credentials: &impl CredentialLookup, -) -> Result { - #[cfg(feature = "antigravity")] - { - let access_token = require_env_value( - credentials, - provider_key, - "antigravity", - env_vars, - "an access token", - is_credential_env_var, - )?; - let project_id = - first_matching_env_value(credentials, env_vars, is_antigravity_project_id_env_var) - .unwrap_or_else(|| serdes_ai_models::antigravity::DEFAULT_PROJECT_ID.to_owned()); - let mut model = - serdes_ai_models::AntigravityModel::new(model_name, access_token, project_id); - if let Some(api_url) = api_url { - model = model.with_config(serdes_ai_models::antigravity::AntigravityConfig { - endpoint: api_url.to_owned(), - ..serdes_ai_models::antigravity::AntigravityConfig::default() - }); - } - Ok(ResolvedSerdesModel::new("antigravity", model_name, model)) - } - #[cfg(not(feature = "antigravity"))] - { - let _ = (provider_key, model_name, api_url, env_vars); - Err(feature_disabled_error("antigravity", "antigravity")) +// ============================================================================= +// Groq (fixed endpoint - no URL override allowed) +// ============================================================================= + +/// Compares two URLs for equality, ignoring trailing slashes. +/// +/// URLs often include or omit trailing slashes inconsistently, but represent the same +/// endpoint. This normalizes both sides before comparison. +#[inline] +fn urls_equal_ignoring_slash(lhs: &str, rhs: &str) -> bool { + lhs.trim_end_matches('/') == rhs.trim_end_matches('/') +} + +/// Normalizes an Azure endpoint URL by removing common redundant path suffixes. +/// +/// Users may copy endpoints from the Azure portal that include `/openai` or `/openai/v1` +/// suffixes, but the Azure SDK constructs the full path internally as +/// `{endpoint}/openai/deployments/{deployment}`. This function strips those suffixes +/// to prevent double paths like `/openai/openai/deployments/...`. +fn normalize_azure_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim().trim_end_matches('/'); + if let Some(stripped) = trimmed.strip_suffix("/openai/v1") { + stripped.to_owned() + } else if let Some(stripped) = trimmed.strip_suffix("/openai") { + stripped.to_owned() + } else { + trimmed.to_owned() } } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs index 52f4793f..09b8555a 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/tests.rs @@ -18,89 +18,6 @@ struct Case { expected_system: &'static str, } -fn config_with_model(name: &str, model: Option<&str>) -> AgentConfig { - AgentConfig { - name: name.into(), - mode: AgentMode::All, - description: Default::default(), - model: model.map(Into::into), - hidden: false, - temperature: None, - top_p: None, - permission: IndexMap::new(), - options: AHashMap::new(), - tool_settings: AgentToolSettings::default(), - prompt: Default::default(), - } -} - -fn provider(api_url: &str, env_vars: &[&str], api_type: ProviderType) -> ProviderInfo { - ProviderInfo { - api_url: api_url.to_string(), - env_vars: env_vars - .iter() - .map(|env_var| (*env_var).to_string()) - .collect(), - api_type, - } -} - -fn model_info(max_input: u32, max_output: u32) -> ModelInfo { - ModelInfo { - modalities: Modality::TEXT, - max_input, - max_output, - temperature: Some(1.0), - top_p: Some(0.95), - } -} - -fn build_catalog( - providers: Vec<(&str, ProviderInfo)>, - provider_models: Vec<(&str, &str, ModelInfo)>, -) -> ModelCatalog { - let provider_sources: Vec = providers - .into_iter() - .map(|(key, info)| ProviderSource::new(key, info)) - .collect(); - let provider_model_sources: Vec> = provider_models - .into_iter() - .map(|(provider_key, model_key, info)| { - let provider_idx = ProviderIdx::new( - provider_sources - .iter() - .position(|provider| provider.provider_key == provider_key) - .expect("provider key should exist") as u16, - ); - ProviderModelSource::new(provider_idx, model_key, info) - }) - .collect(); - ModelCatalog::build(&provider_sources, &provider_model_sources) - .expect("catalog fixture should build") -} - -fn resolve_case(case: &Case) -> ResolvedSerdesModel { - let catalog = build_catalog( - vec![(case.provider_key, case.provider.clone())], - vec![( - case.provider_key, - case.model_name, - model_info(128_000, 16_384), - )], - ); - let model = format!("{}/{}", case.provider_key, case.model_name); - let defaults = AgentDefaults::with_model(&*model); - let agent = config_with_model("planner", None); - let mut credentials = CredentialResolver::without_env(); - for (name, value) in case.credential_updates { - if let Some(value) = value { - credentials.set_override(*name, *value); - } - } - let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - build_serdes_model(&catalog, &resolved, &credentials).expect("model should build") -} - #[cfg(feature = "bedrock")] #[test] fn build_bedrock_ignores_process_env_when_resolver_disables_env_fallback() { @@ -133,6 +50,90 @@ fn build_bedrock_ignores_process_env_when_resolver_disables_env_fallback() { ); } +#[test] +fn build_openai_chat_still_requires_credential_when_env_vars_present() { + // Existing behavior: when env vars list credentials, they must be set. + // This is a regression test to ensure the optional-key change doesn't + // break providers that do require credentials. + let catalog = build_catalog( + vec![( + "remote-compat", + provider( + "https://api.example.com/v1", + &["EXAMPLE_API_KEY"], // Credential env var listed + ProviderType::OpenAiCompletions, + ), + )], + vec![("remote-compat", "my-model", model_info(128_000, 8_192))], + ); + let defaults = AgentDefaults::with_model("remote-compat/my-model"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let err = build_serdes_model(&catalog, &resolved, &credentials) + .err() + .expect("should fail without credentials"); + assert!( + err.to_string() + .contains("provider `remote-compat` mapped to serdes `openai` requires a credential"), + "error was: {err}" + ); +} + +#[test] +fn build_openai_chat_succeeds_with_non_credential_env_vars() { + // Env vars that don't match credential patterns (no _API_KEY/_TOKEN/_ACCESS_TOKEN suffix) + // should behave like no-credential - empty key is used. + let catalog = build_catalog( + vec![( + "compat-noncred", + provider( + "http://localhost:11434/v1", + &["MY_BASE_URL"], // Not a credential env var + ProviderType::OpenAiCompletions, + ), + )], + vec![("compat-noncred", "llama3", model_info(8_192, 4_096))], + ); + let defaults = AgentDefaults::with_model("compat-noncred/llama3"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let result = build_serdes_model(&catalog, &resolved, &credentials); + assert!( + result.is_ok(), + "should succeed with non-credential env vars" + ); +} + +#[test] +fn build_openai_chat_succeeds_without_credential_when_no_env_vars() { + // A provider with no credential env vars should build successfully + // even without any API key set (e.g., local OpenAI-compatible endpoints). + let catalog = build_catalog( + vec![( + "local-compat", + provider( + "http://localhost:11434/v1", + &[], // No env vars listed - no credential required + ProviderType::OpenAiCompletions, + ), + )], + vec![("local-compat", "llama3", model_info(8_192, 4_096))], + ); + let defaults = AgentDefaults::with_model("local-compat/llama3"); + let agent = config_with_model("planner", None); + let credentials = CredentialResolver::without_env(); + + let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); + let result = build_serdes_model(&catalog, &resolved, &credentials); + assert!(result.is_ok(), "should succeed with no credential env vars"); + let model = result.expect("should build"); + assert_eq!(model.spec.as_ref(), "openai:llama3"); +} + #[test] fn build_serdes_model_covers_every_provider_mapping() { let mut cases = Vec::with_capacity(15); @@ -349,32 +350,23 @@ fn build_serdes_model_covers_every_provider_mapping() { } #[test] -fn build_serdes_model_skips_empty_credential_env_vars() { +fn build_serdes_model_rejects_unknown_provider_type() { let catalog = build_catalog( - vec![( - "synthetic", - provider( - "https://api.synthetic.new/v1", - &["PRIMARY_API_KEY", "SECONDARY_API_KEY"], - ProviderType::OpenAiCompletions, - ), - )], - vec![( - "synthetic", - "hf:zai-org/GLM-4.7", - model_info(128_000, 16_384), - )], + vec![("mystery", provider("", &[], ProviderType::Unknown))], + vec![("mystery", "m1", model_info(1, 1))], ); - let defaults = AgentDefaults::with_model("synthetic/hf:zai-org/GLM-4.7"); + let defaults = AgentDefaults::with_model("mystery/m1"); let agent = config_with_model("planner", None); - let mut credentials = CredentialResolver::without_env(); - credentials.set_override("PRIMARY_API_KEY", ""); - credentials.set_override("SECONDARY_API_KEY", "fallback-key"); + let credentials = CredentialResolver::without_env(); let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - let serdes_model = - build_serdes_model(&catalog, &resolved, &credentials).expect("model should build"); - assert_eq!(serdes_model.spec.as_ref(), "openai:hf:zai-org/GLM-4.7"); + let err = build_serdes_model(&catalog, &resolved, &credentials) + .err() + .expect("model should fail"); + assert!( + err.to_string() + .contains("provider `mystery` has no SerdesAI mapping") + ); } #[test] @@ -410,105 +402,113 @@ fn build_serdes_model_returns_clear_error_when_required_credential_missing() { } #[test] -fn build_serdes_model_rejects_unknown_provider_type() { - let catalog = build_catalog( - vec![("mystery", provider("", &[], ProviderType::Unknown))], - vec![("mystery", "m1", model_info(1, 1))], - ); - let defaults = AgentDefaults::with_model("mystery/m1"); - let agent = config_with_model("planner", None); - let credentials = CredentialResolver::without_env(); - - let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - let err = build_serdes_model(&catalog, &resolved, &credentials) - .err() - .expect("model should fail"); - assert!( - err.to_string() - .contains("provider `mystery` has no SerdesAI mapping") - ); -} - -#[test] -fn build_openai_chat_succeeds_without_credential_when_no_env_vars() { - // A provider with no credential env vars should build successfully - // even without any API key set (e.g., local OpenAI-compatible endpoints). +fn build_serdes_model_skips_empty_credential_env_vars() { let catalog = build_catalog( vec![( - "local-compat", + "synthetic", provider( - "http://localhost:11434/v1", - &[], // No env vars listed - no credential required + "https://api.synthetic.new/v1", + &["PRIMARY_API_KEY", "SECONDARY_API_KEY"], ProviderType::OpenAiCompletions, ), )], - vec![("local-compat", "llama3", model_info(8_192, 4_096))], + vec![( + "synthetic", + "hf:zai-org/GLM-4.7", + model_info(128_000, 16_384), + )], ); - let defaults = AgentDefaults::with_model("local-compat/llama3"); + let defaults = AgentDefaults::with_model("synthetic/hf:zai-org/GLM-4.7"); let agent = config_with_model("planner", None); - let credentials = CredentialResolver::without_env(); + let mut credentials = CredentialResolver::without_env(); + credentials.set_override("PRIMARY_API_KEY", ""); + credentials.set_override("SECONDARY_API_KEY", "fallback-key"); let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - let result = build_serdes_model(&catalog, &resolved, &credentials); - assert!(result.is_ok(), "should succeed with no credential env vars"); - let model = result.expect("should build"); - assert_eq!(model.spec.as_ref(), "openai:llama3"); + let serdes_model = + build_serdes_model(&catalog, &resolved, &credentials).expect("model should build"); + assert_eq!(serdes_model.spec.as_ref(), "openai:hf:zai-org/GLM-4.7"); } -#[test] -fn build_openai_chat_still_requires_credential_when_env_vars_present() { - // Existing behavior: when env vars list credentials, they must be set. - // This is a regression test to ensure the optional-key change doesn't - // break providers that do require credentials. +fn model_info(max_input: u32, max_output: u32) -> ModelInfo { + ModelInfo { + modalities: Modality::TEXT, + max_input, + max_output, + temperature: Some(1.0), + top_p: Some(0.95), + } +} + +fn resolve_case(case: &Case) -> ResolvedSerdesModel { let catalog = build_catalog( + vec![(case.provider_key, case.provider.clone())], vec![( - "remote-compat", - provider( - "https://api.example.com/v1", - &["EXAMPLE_API_KEY"], // Credential env var listed - ProviderType::OpenAiCompletions, - ), + case.provider_key, + case.model_name, + model_info(128_000, 16_384), )], - vec![("remote-compat", "my-model", model_info(128_000, 8_192))], ); - let defaults = AgentDefaults::with_model("remote-compat/my-model"); + let model = format!("{}/{}", case.provider_key, case.model_name); + let defaults = AgentDefaults::with_model(&*model); let agent = config_with_model("planner", None); - let credentials = CredentialResolver::without_env(); - + let mut credentials = CredentialResolver::without_env(); + for (name, value) in case.credential_updates { + if let Some(value) = value { + credentials.set_override(*name, *value); + } + } let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - let err = build_serdes_model(&catalog, &resolved, &credentials) - .err() - .expect("should fail without credentials"); - assert!( - err.to_string() - .contains("provider `remote-compat` mapped to serdes `openai` requires a credential"), - "error was: {err}" - ); + build_serdes_model(&catalog, &resolved, &credentials).expect("model should build") } -#[test] -fn build_openai_chat_succeeds_with_non_credential_env_vars() { - // Env vars that don't match credential patterns (no _API_KEY/_TOKEN/_ACCESS_TOKEN suffix) - // should behave like no-credential - empty key is used. - let catalog = build_catalog( - vec![( - "compat-noncred", - provider( - "http://localhost:11434/v1", - &["MY_BASE_URL"], // Not a credential env var - ProviderType::OpenAiCompletions, - ), - )], - vec![("compat-noncred", "llama3", model_info(8_192, 4_096))], - ); - let defaults = AgentDefaults::with_model("compat-noncred/llama3"); - let agent = config_with_model("planner", None); - let credentials = CredentialResolver::without_env(); +fn build_catalog( + providers: Vec<(&str, ProviderInfo)>, + provider_models: Vec<(&str, &str, ModelInfo)>, +) -> ModelCatalog { + let provider_sources: Vec = providers + .into_iter() + .map(|(key, info)| ProviderSource::new(key, info)) + .collect(); + let provider_model_sources: Vec> = provider_models + .into_iter() + .map(|(provider_key, model_key, info)| { + let provider_idx = ProviderIdx::new( + provider_sources + .iter() + .position(|provider| provider.provider_key == provider_key) + .expect("provider key should exist") as u16, + ); + ProviderModelSource::new(provider_idx, model_key, info) + }) + .collect(); + ModelCatalog::build(&provider_sources, &provider_model_sources) + .expect("catalog fixture should build") +} - let resolved = resolve_model(&catalog, &defaults, &agent).expect("model should resolve"); - let result = build_serdes_model(&catalog, &resolved, &credentials); - assert!( - result.is_ok(), - "should succeed with non-credential env vars" - ); +fn config_with_model(name: &str, model: Option<&str>) -> AgentConfig { + AgentConfig { + name: name.into(), + mode: AgentMode::All, + description: Default::default(), + model: model.map(Into::into), + hidden: false, + temperature: None, + top_p: None, + permission: IndexMap::new(), + options: AHashMap::new(), + tool_settings: AgentToolSettings::default(), + prompt: Default::default(), + } +} + +fn provider(api_url: &str, env_vars: &[&str], api_type: ProviderType) -> ProviderInfo { + ProviderInfo { + api_url: api_url.to_string(), + env_vars: env_vars + .iter() + .map(|env_var| (*env_var).to_string()) + .collect(), + api_type, + } } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 8e7f1f74..ca04bc05 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -3,9 +3,13 @@ //! # Public API //! - [`AgentBuildContext`] - Reusable shared inputs for building runnable agents. +#[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] +use super::build::Profile; use super::build::{AgentBuildError, attach_standard_tools, prepare_build}; use crate::task::TaskHandle; use reloaded_code_agents::AgentRuntime; +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +use reloaded_code_bubblewrap::{CreateSandboxError, Preset, Profile, TempSandboxDirs}; use reloaded_code_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; use serdes_ai::{Agent, AgentBuilder}; #[cfg(any(test, feature = "mock"))] @@ -13,12 +17,6 @@ use serdes_ai_models::BoxedModel; use std::path::Path; use std::sync::Arc; -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -use reloaded_code_bubblewrap::{CreateSandboxError, Preset, Profile, TempSandboxDirs}; - -#[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] -use super::build::Profile; - /// Reusable shared inputs for building runnable SerdesAI agents. /// /// Create once and call [`AgentBuildContext::build`] for each catalog agent @@ -30,19 +28,37 @@ pub struct AgentBuildContext>, } +/// Shared owned state for builds that may happen later during Task delegation. +#[derive(Clone)] +pub(crate) struct TaskBuildContext +{ + runtime: Arc, + model_catalog: Arc, + credentials: Arc, + workspace_root: Arc, + #[cfg(any(test, feature = "mock"))] + model_override: Option, + #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] + bash_sandbox: Option>, + #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] + _sandbox_tmpdir: Option>, +} + impl AgentBuildContext where C: CredentialLookup + Send + Sync + 'static, { /// Creates a shared build context without a sandbox. /// - /// [`BashTool`](crate::BashTool) will run commands directly on the host. + /// [`BashTool`] will run commands directly on the host. /// /// # Platform /// /// For sandboxed builds on Linux with the `linux-bubblewrap` feature, use /// `new_with_sandbox` or `new_with_temp_sandbox` instead. /// + /// [`BashTool`]: crate::BashTool + /// /// # Arguments /// - `runtime`: Shared agent runtime holding the catalog and defaults. /// - `model_catalog`: Available models for agent resolution. @@ -80,7 +96,7 @@ where /// - `model_catalog`: Available models for agent resolution. /// - `credentials`: Credential lookup used to authenticate model requests. /// - `workspace_root`: Project directory exposed to tools. - /// - `profile`: Pre-built sandbox profile for [`BashTool`](crate::BashTool). + /// - `profile`: Pre-built sandbox profile for [`BashTool`]. /// - `sandbox_tmpdir`: Optional owning temp directories that keep the /// profile's backing storage alive for the context's lifetime. /// @@ -224,22 +240,6 @@ where } } -/// Shared owned state for builds that may happen later during Task delegation. -#[derive(Clone)] -pub(crate) struct TaskBuildContext -{ - runtime: Arc, - model_catalog: Arc, - credentials: Arc, - workspace_root: Arc, - #[cfg(any(test, feature = "mock"))] - model_override: Option, - #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] - bash_sandbox: Option>, - #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] - _sandbox_tmpdir: Option>, -} - impl TaskBuildContext where C: CredentialLookup + Send + Sync + 'static, @@ -260,7 +260,7 @@ where /// - `model_catalog`: Available models for agent resolution. /// - `credentials`: Credential lookup used to authenticate model requests. /// - `workspace_root`: Project directory exposed to tools. - /// - `bash_sandbox`: Pre-built sandbox profile for [`BashTool`](crate::BashTool). + /// - `bash_sandbox`: Pre-built sandbox profile for [`BashTool`]. /// - `_sandbox_tmpdir`: Optional owning temp directories that keep the /// profile's backing storage alive. #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] diff --git a/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs index 982d7cc5..99433f22 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs @@ -7,41 +7,6 @@ use reloaded_code_core::{ }; use std::sync::Arc; -/// A minimal portable custom tool that returns a configurable text response. -struct SerdesTestTool { - name: &'static str, - prompt: &'static str, - response: &'static str, -} - -impl ToolContext for SerdesTestTool { - #[inline] - fn name(&self) -> &'static str { - self.name - } - - #[inline] - fn context(&self) -> ToolPrompt { - ToolPrompt::Static(self.prompt) - } -} - -impl CustomTool for SerdesTestTool { - #[inline] - fn definition(&self) -> CustomToolDefinition { - CustomToolDefinition::new(self.name, self.name) - } - - #[inline] - fn call<'a>( - &'a self, - _ctx: ToolRunContext<'a>, - _args: serde_json::Value, - ) -> CustomToolFuture<'a> { - Box::pin(async move { Ok(ToolOutput::new(self.response)) }) - } -} - /// A `ToolFactory` that creates a portable [`SerdesTestTool`]. /// /// `name` and `prompt` are surfaced via `ToolContext` for system-prompt @@ -56,6 +21,13 @@ pub struct SerdesTestFactory { pub response: &'static str, } +/// A minimal portable custom tool that returns a configurable text response. +struct SerdesTestTool { + name: &'static str, + prompt: &'static str, + response: &'static str, +} + impl SerdesTestFactory { /// Creates a new factory that produces a tool named `name`, with system-prompt /// guidance `prompt`, and `call()` returning `response`. @@ -91,3 +63,31 @@ impl ToolFactory for SerdesTestFactory { })) } } + +impl ToolContext for SerdesTestTool { + #[inline] + fn name(&self) -> &'static str { + self.name + } + + #[inline] + fn context(&self) -> ToolPrompt { + ToolPrompt::Static(self.prompt) + } +} + +impl CustomTool for SerdesTestTool { + #[inline] + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new(self.name, self.name) + } + + #[inline] + fn call<'a>( + &'a self, + _ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { Ok(ToolOutput::new(self.response)) }) + } +} diff --git a/src/reloaded-code-serdesai/src/convert.rs b/src/reloaded-code-serdesai/src/convert.rs index ee1367b5..200af44e 100644 --- a/src/reloaded-code-serdesai/src/convert.rs +++ b/src/reloaded-code-serdesai/src/convert.rs @@ -11,22 +11,35 @@ use reloaded_code_core::{ use serde_json::json; use serdes_ai::tools::{ToolDefinition, ToolError as SerdesError, ToolReturn}; -/// Convert [`ToolOutput`] to [`ToolReturn`] (serdesAI). +/// Convert a portable [`CustomToolDefinition`] to a SerdesAI [`ToolDefinition`]. /// -/// - Non-truncated output: `ToolReturn::text(content)` -/// - Truncated output: `ToolReturn::json({ "content": ..., "truncated": true })` +/// Fields map 1:1. The SerdesAI `outer_typed_dict_key` field is always `None` +/// because portable definitions do not carry framework-specific metadata. /// -/// [`ToolOutput`]: reloaded_code_core::ToolOutput -/// [`ToolReturn`]: serdes_ai::tools::ToolReturn +/// # Example +/// +/// ``` +/// use reloaded_code_serdesai::convert::custom_definition_to_serdes; +/// use reloaded_code_core::CustomToolDefinition; +/// use serde_json::json; +/// +/// let def = CustomToolDefinition::new("my_tool", "Does things") +/// .with_parameters(json!({"type": "object", "properties": {}})) +/// .with_strict(true); +/// +/// let serdes_def = custom_definition_to_serdes(def); +/// assert_eq!(serdes_def.name, "my_tool"); +/// assert_eq!(serdes_def.strict, Some(true)); +/// assert!(serdes_def.outer_typed_dict_key.is_none()); +/// ``` #[inline] -fn output_to_return(output: ToolOutput) -> ToolReturn { - if output.truncated { - ToolReturn::json(json!({ - "content": output.content, - "truncated": true - })) - } else { - ToolReturn::text(output.content) +pub fn custom_definition_to_serdes(definition: CustomToolDefinition) -> ToolDefinition { + ToolDefinition { + name: definition.name, + description: definition.description, + parameters_json_schema: definition.parameters_json_schema, + strict: definition.strict, + outer_typed_dict_key: None, } } @@ -111,35 +124,22 @@ fn field_for_out_of_bounds(msg: &str) -> Option { } } -/// Convert a portable [`CustomToolDefinition`] to a SerdesAI [`ToolDefinition`]. -/// -/// Fields map 1:1. The SerdesAI `outer_typed_dict_key` field is always `None` -/// because portable definitions do not carry framework-specific metadata. -/// -/// # Example -/// -/// ``` -/// use reloaded_code_serdesai::convert::custom_definition_to_serdes; -/// use reloaded_code_core::CustomToolDefinition; -/// use serde_json::json; +/// Convert [`ToolOutput`] to [`ToolReturn`] (serdesAI). /// -/// let def = CustomToolDefinition::new("my_tool", "Does things") -/// .with_parameters(json!({"type": "object", "properties": {}})) -/// .with_strict(true); +/// - Non-truncated output: `ToolReturn::text(content)` +/// - Truncated output: `ToolReturn::json({ "content": ..., "truncated": true })` /// -/// let serdes_def = custom_definition_to_serdes(def); -/// assert_eq!(serdes_def.name, "my_tool"); -/// assert_eq!(serdes_def.strict, Some(true)); -/// assert!(serdes_def.outer_typed_dict_key.is_none()); -/// ``` +/// [`ToolOutput`]: reloaded_code_core::ToolOutput +/// [`ToolReturn`]: serdes_ai::tools::ToolReturn #[inline] -pub fn custom_definition_to_serdes(definition: CustomToolDefinition) -> ToolDefinition { - ToolDefinition { - name: definition.name, - description: definition.description, - parameters_json_schema: definition.parameters_json_schema, - strict: definition.strict, - outer_typed_dict_key: None, +fn output_to_return(output: ToolOutput) -> ToolReturn { + if output.truncated { + ToolReturn::json(json!({ + "content": output.content, + "truncated": true + })) + } else { + ToolReturn::text(output.content) } } diff --git a/src/reloaded-code-serdesai/src/lib.rs b/src/reloaded-code-serdesai/src/lib.rs index 73e995cb..23e882de 100644 --- a/src/reloaded-code-serdesai/src/lib.rs +++ b/src/reloaded-code-serdesai/src/lib.rs @@ -1,46 +1,32 @@ #![doc = include_str!(concat!("../", env!("CARGO_PKG_README")))] #![warn(missing_docs)] -pub mod agent_ext; -pub mod agent_runtime; -pub mod convert; -pub mod task; -pub mod tools; - -/// Re-export core types for convenience. -pub use reloaded_code_core::{TaskSettings, ToolError, ToolOutput, ToolResult}; - -/// Re-export bash execution mode and mode-aware execution. -pub use reloaded_code_core::{BashExecutionMode, execute_command_with_mode}; - /// Re-export preferred Linux bubblewrap profile types #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] pub use reloaded_code_bubblewrap::profile; - +/// Re-export [`SystemPromptBuilder`] from core. +pub use reloaded_code_core::SystemPromptBuilder; /// Re-export context module and [`ToolContext`] trait for convenience. pub use reloaded_code_core::ToolContext; pub use reloaded_code_core::context; - -/// Re-export [`SystemPromptBuilder`] from core. -pub use reloaded_code_core::SystemPromptBuilder; - /// Re-export path resolvers from core. pub use reloaded_code_core::path::{ AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, PathResolver, }; - +/// Re-export bash execution mode and mode-aware execution. +pub use reloaded_code_core::{BashExecutionMode, execute_command_with_mode}; +/// Re-export core types for convenience. +pub use reloaded_code_core::{TaskSettings, ToolError, ToolOutput, ToolResult}; // Re-export tools from the tools module pub use tools::{ BashTool, CustomToolAdapter, EditTool, GlobTool, GrepTool, ReadTool, TodoReadTool, TodoWriteTool, WebFetchTool, WriteTool, create_todo_tools, }; - // Re-export core operation types used by tools pub use reloaded_code_core::{ BashOutput, EditError, GlobOutput, GrepFileMatches, GrepLineMatch, GrepOutput, Todo, TodoPriority, TodoState, TodoStatus, WebFetchOutput, }; - // Re-export standalone tools and runtime helpers pub use agent_runtime::{AgentBuildContext, AgentBuildError}; pub use reloaded_code_agents::{ @@ -48,5 +34,10 @@ pub use reloaded_code_agents::{ resolve_model_with_catalog, }; +pub mod agent_ext; +pub mod agent_runtime; +pub mod convert; #[cfg(any(test, feature = "mock"))] pub mod mock; +pub mod task; +pub mod tools; diff --git a/src/reloaded-code-serdesai/src/mock.rs b/src/reloaded-code-serdesai/src/mock.rs index e696de1f..ca4c4986 100644 --- a/src/reloaded-code-serdesai/src/mock.rs +++ b/src/reloaded-code-serdesai/src/mock.rs @@ -18,14 +18,13 @@ //! to inject the mock model before calling [`build()`](crate::AgentBuildContext::build). // Re-export upstream mock types so users can still access the raw variants when needed. -pub use serdes_ai_models::{FunctionModel, MockModel, TestModel}; - use async_trait::async_trait; use futures::stream; use serdes_ai::core::{ FinishReason, ModelRequest, ModelResponse, ModelResponsePart, ModelResponseStreamEvent, }; use serdes_ai_models::Model as ModelTrait; +pub use serdes_ai_models::{FunctionModel, MockModel, TestModel}; // Re-export the types from where serdes-ai-models exposes them. use serdes_ai::core::ModelSettings; use serdes_ai_models::{ @@ -182,21 +181,6 @@ pub fn tool_then_text( Streamed::new(model) } -// ============================================================================ -// Private helpers -// ============================================================================ - -fn response_to_stream_events(response: ModelResponse) -> Vec { - let mut events = Vec::with_capacity(response.parts.len() * 2 + 1); - - for (index, part) in response.parts.into_iter().enumerate() { - events.push(ModelResponseStreamEvent::part_start(index, part)); - events.push(ModelResponseStreamEvent::part_end(index)); - } - - events -} - /// Extract human-readable text from a [`ToolReturnPart`]. /// /// Uses serde JSON round-tripping to avoid depending on the @@ -233,3 +217,18 @@ fn extract_tool_return_text(tr: &serdes_ai::core::ToolReturnPart) -> String { // Fallback: pretty-print the whole thing. serde_json::to_string_pretty(&val).unwrap_or_else(|_| format!("{:?}", tr.content)) } + +// ============================================================================ +// Private helpers +// ============================================================================ + +fn response_to_stream_events(response: ModelResponse) -> Vec { + let mut events = Vec::with_capacity(response.parts.len() * 2 + 1); + + for (index, part) in response.parts.into_iter().enumerate() { + events.push(ModelResponseStreamEvent::part_start(index, part)); + events.push(ModelResponseStreamEvent::part_end(index)); + } + + events +} diff --git a/src/reloaded-code-serdesai/src/task/definition.rs b/src/reloaded-code-serdesai/src/task/definition.rs index fb00f36c..47effd97 100644 --- a/src/reloaded-code-serdesai/src/task/definition.rs +++ b/src/reloaded-code-serdesai/src/task/definition.rs @@ -8,27 +8,6 @@ use reloaded_code_agents::TaskTargetSummary; use reloaded_code_core::tool_metadata::task as task_meta; use serdes_ai::tools::{SchemaBuilder, ToolDefinition}; -/// Renders callable target summaries in a stable, user-facing format. -pub(crate) fn render_task_targets(targets: &[TaskTargetSummary]) -> String { - if targets.is_empty() { - return "No callable subagents are available.".to_string(); - } - - let mut ordered: Vec<_> = targets.iter().collect(); - ordered.sort_unstable_by(|left, right| left.name.as_ref().cmp(right.name.as_ref())); - - let mut rendered = String::with_capacity(32 + ordered.len() * 64); - rendered.push_str("Available subagents:\n"); - for target in ordered { - rendered.push_str("- "); - rendered.push_str(target.name.as_ref()); - rendered.push_str(": "); - rendered.push_str(target.description.as_ref()); - rendered.push('\n'); - } - rendered -} - /// Builds a SerdesAI Task definition using the shared target summaries. pub(crate) fn task_tool_definition(targets: &[TaskTargetSummary]) -> ToolDefinition { let rendered_targets = render_task_targets(targets); @@ -70,6 +49,27 @@ pub(crate) fn task_tool_definition(targets: &[TaskTargetSummary]) -> ToolDefinit } } +/// Renders callable target summaries in a stable, user-facing format. +pub(crate) fn render_task_targets(targets: &[TaskTargetSummary]) -> String { + if targets.is_empty() { + return "No callable subagents are available.".to_string(); + } + + let mut ordered: Vec<_> = targets.iter().collect(); + ordered.sort_unstable_by(|left, right| left.name.as_ref().cmp(right.name.as_ref())); + + let mut rendered = String::with_capacity(32 + ordered.len() * 64); + rendered.push_str("Available subagents:\n"); + for target in ordered { + rendered.push_str("- "); + rendered.push_str(target.name.as_ref()); + rendered.push_str(": "); + rendered.push_str(target.description.as_ref()); + rendered.push('\n'); + } + rendered +} + #[cfg(test)] mod tests { use super::{task_meta, *}; diff --git a/src/reloaded-code-serdesai/src/task/handle.rs b/src/reloaded-code-serdesai/src/task/handle.rs index b2f17c39..6165045a 100644 --- a/src/reloaded-code-serdesai/src/task/handle.rs +++ b/src/reloaded-code-serdesai/src/task/handle.rs @@ -17,18 +17,6 @@ pub(crate) struct TaskHandle Clone for TaskHandle -where - C: CredentialLookup + Send + Sync + 'static, -{ - fn clone(&self) -> Self { - Self { - context: Arc::clone(&self.context), - current_depth: self.current_depth, - } - } -} - impl TaskHandle where C: CredentialLookup + Send + Sync + 'static, @@ -148,6 +136,18 @@ where } } +impl Clone for TaskHandle +where + C: CredentialLookup + Send + Sync + 'static, +{ + fn clone(&self) -> Self { + Self { + context: Arc::clone(&self.context), + current_depth: self.current_depth, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/src/task/mod.rs b/src/reloaded-code-serdesai/src/task/mod.rs index bf175075..7480e78d 100644 --- a/src/reloaded-code-serdesai/src/task/mod.rs +++ b/src/reloaded-code-serdesai/src/task/mod.rs @@ -9,10 +9,10 @@ //! external callers use the adapter's public API instead of constructing Task //! tools by hand. -mod definition; -mod handle; -mod tool; - pub(crate) use definition::task_tool_definition; pub(crate) use handle::TaskHandle; pub(crate) use tool::TaskTool; + +mod definition; +mod handle; +mod tool; diff --git a/src/reloaded-code-serdesai/src/tools/bash.rs b/src/reloaded-code-serdesai/src/tools/bash.rs index fc11cfc3..9e333dcf 100644 --- a/src/reloaded-code-serdesai/src/tools/bash.rs +++ b/src/reloaded-code-serdesai/src/tools/bash.rs @@ -26,6 +26,8 @@ use crate::convert::{core_error_to_serdes, to_serdes_result}; use async_trait::async_trait; +#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] +use reloaded_code_bubblewrap::profile::{NetworkPolicy, Profile}; use reloaded_code_core::context::{ToolContext, ToolPrompt}; use reloaded_code_core::permissions::Ruleset; use reloaded_code_core::tool_metadata::bash as bash_meta; @@ -34,9 +36,6 @@ use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResu use std::path::PathBuf; use std::sync::Arc; -#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] -use reloaded_code_bubblewrap::profile::{NetworkPolicy, Profile}; - /// Tool for executing shell commands. /// /// Uses bash on Unix, cmd on Windows. @@ -55,12 +54,6 @@ pub struct BashTool { permission: Option>, } -impl Default for BashTool { - fn default() -> Self { - Self::host() - } -} - impl BashTool { /// Creates a new bash tool instance with default settings. /// @@ -191,6 +184,12 @@ impl BashTool { } } +impl Default for BashTool { + fn default() -> Self { + Self::host() + } +} + #[async_trait] impl Tool for BashTool { fn definition(&self) -> ToolDefinition { @@ -226,6 +225,19 @@ impl Tool for BashTool { } } +impl ToolContext for BashTool { + fn name(&self) -> &'static str { + bash_meta::NAME + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Bash { + network_disabled: bash_prompt_network_disabled(&self.mode), + sandboxed: bash_prompt_sandboxed(&self.mode), + } + } +} + #[inline] fn bash_prompt_network_disabled(mode: &BashExecutionMode) -> bool { #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] @@ -258,19 +270,6 @@ fn bash_prompt_sandboxed(mode: &BashExecutionMode) -> bool { } } -impl ToolContext for BashTool { - fn name(&self) -> &'static str { - bash_meta::NAME - } - - fn context(&self) -> ToolPrompt { - ToolPrompt::Bash { - network_disabled: bash_prompt_network_disabled(&self.mode), - sandboxed: bash_prompt_sandboxed(&self.mode), - } - } -} - fn build_definition(max_timeout_ms: u32) -> ToolDefinition { ToolDefinition { name: bash_meta::NAME.to_owned(), diff --git a/src/reloaded-code-serdesai/src/tools/edit.rs b/src/reloaded-code-serdesai/src/tools/edit.rs index a7bf6a19..7cf82bd6 100644 --- a/src/reloaded-code-serdesai/src/tools/edit.rs +++ b/src/reloaded-code-serdesai/src/tools/edit.rs @@ -9,6 +9,7 @@ //! //! [`Tool`]: serdes_ai::tools::Tool +use crate::convert::core_error_to_serdes; use async_trait::async_trait; use reloaded_code_core::ToolContext; use reloaded_code_core::context::{PathMode, ToolPrompt}; @@ -17,8 +18,6 @@ use reloaded_code_core::tool_metadata::edit as edit_meta; use reloaded_code_core::tools::{EditRequest, EditSettings, edit_file}; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult, ToolReturn}; -use crate::convert::core_error_to_serdes; - /// Tool for making exact string replacements in files. /// /// Generic over any [`PathResolver`] implementation. diff --git a/src/reloaded-code-serdesai/src/tools/glob.rs b/src/reloaded-code-serdesai/src/tools/glob.rs index 76f03c69..98d0548d 100644 --- a/src/reloaded-code-serdesai/src/tools/glob.rs +++ b/src/reloaded-code-serdesai/src/tools/glob.rs @@ -9,6 +9,7 @@ //! //! [`Tool`]: serdes_ai::tools::Tool +use crate::convert::core_error_to_serdes; use async_trait::async_trait; use reloaded_code_core::ToolContext; use reloaded_code_core::context::{PathMode, ToolPrompt}; @@ -18,7 +19,7 @@ use reloaded_code_core::tools::{GlobOutput, GlobRequest, GlobSettings, glob_file use serde_json::json; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult, ToolReturn}; -use crate::convert::core_error_to_serdes; +const NO_FILES_FOUND: &str = "No files found matching the pattern."; /// Tool for finding files matching glob patterns. /// @@ -83,38 +84,6 @@ impl Tool for Gl } } -const NO_FILES_FOUND: &str = "No files found matching the pattern."; - -fn output_content(files: &[String]) -> String { - if files.is_empty() { - NO_FILES_FOUND.to_string() - } else { - files.join("\n") - } -} - -fn glob_output_to_return(output: GlobOutput) -> ToolReturn { - let content = output_content(&output.files); - - if output.partial { - return ToolReturn::json(json!({ - "content": content, - "partial": true, - "errors": output.errors, - "truncated": output.truncated, - })); - } - - if output.truncated { - ToolReturn::json(json!({ - "content": content, - "truncated": true, - })) - } else { - ToolReturn::text(content) - } -} - impl ToolContext for GlobTool { fn name(&self) -> &'static str { glob_meta::NAME @@ -158,6 +127,36 @@ fn build_definition(path_mode: PathMode) -> ToolDefinition { } } +fn glob_output_to_return(output: GlobOutput) -> ToolReturn { + let content = output_content(&output.files); + + if output.partial { + return ToolReturn::json(json!({ + "content": content, + "partial": true, + "errors": output.errors, + "truncated": output.truncated, + })); + } + + if output.truncated { + ToolReturn::json(json!({ + "content": content, + "truncated": true, + })) + } else { + ToolReturn::text(content) + } +} + +fn output_content(files: &[String]) -> String { + if files.is_empty() { + NO_FILES_FOUND.to_string() + } else { + files.join("\n") + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/src/tools/grep.rs b/src/reloaded-code-serdesai/src/tools/grep.rs index c2720093..047695de 100644 --- a/src/reloaded-code-serdesai/src/tools/grep.rs +++ b/src/reloaded-code-serdesai/src/tools/grep.rs @@ -10,6 +10,7 @@ //! //! [`Tool`]: serdes_ai::tools::Tool +use crate::convert::{core_error_to_serdes, to_serdes_result}; use async_trait::async_trait; use reloaded_code_core::ToolContext; use reloaded_code_core::context::{PathMode, ToolPrompt}; @@ -21,7 +22,7 @@ use reloaded_code_core::tools::{ use serde_json::json; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult, ToolReturn}; -use crate::convert::{core_error_to_serdes, to_serdes_result}; +const NO_MATCHES_FOUND: &str = "No matches found."; /// Tool for searching file contents using regex patterns. /// @@ -96,27 +97,6 @@ impl Tool for Gr } } -const NO_MATCHES_FOUND: &str = "No matches found."; - -fn grep_output_to_return(output: GrepOutput, formatting: GrepFormattingSettings) -> ToolReturn { - if output.partial { - let content = output.format(formatting); - return ToolReturn::json(json!({ - "content": content, - "partial": true, - "errors": output.errors, - "match_count": output.match_count, - "truncated": output.truncated, - })); - } - - if output.files.is_empty() { - return ToolReturn::text(NO_MATCHES_FOUND); - } - - ToolReturn::text(output.format(formatting)) -} - impl ToolContext for GrepTool { fn name(&self) -> &'static str { grep_meta::NAME @@ -173,6 +153,25 @@ fn build_definition(path_mode: PathMode, line_numbers: bool) -> ToolDefinition { } } +fn grep_output_to_return(output: GrepOutput, formatting: GrepFormattingSettings) -> ToolReturn { + if output.partial { + let content = output.format(formatting); + return ToolReturn::json(json!({ + "content": content, + "partial": true, + "errors": output.errors, + "match_count": output.match_count, + "truncated": output.truncated, + })); + } + + if output.files.is_empty() { + return ToolReturn::text(NO_MATCHES_FOUND); + } + + ToolReturn::text(output.format(formatting)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/src/tools/mod.rs b/src/reloaded-code-serdesai/src/tools/mod.rs index d1a07f0c..7ba56108 100644 --- a/src/reloaded-code-serdesai/src/tools/mod.rs +++ b/src/reloaded-code-serdesai/src/tools/mod.rs @@ -53,16 +53,6 @@ //! [`Tool`]: serdes_ai::tools::Tool //! [`AllowedGlobResolver`]: reloaded_code_core::path::AllowedGlobResolver -mod bash; -mod custom; -mod edit; -mod glob; -mod grep; -mod read; -pub mod todo; -mod webfetch; -mod write; - pub use bash::BashTool; pub use custom::CustomToolAdapter; pub use edit::EditTool; @@ -72,3 +62,13 @@ pub use read::ReadTool; pub use todo::{TodoReadTool, TodoWriteTool, create_todo_tools}; pub use webfetch::WebFetchTool; pub use write::WriteTool; + +mod bash; +mod custom; +mod edit; +mod glob; +mod grep; +mod read; +pub mod todo; +mod webfetch; +mod write; diff --git a/src/reloaded-code-serdesai/src/tools/read.rs b/src/reloaded-code-serdesai/src/tools/read.rs index 39718225..b8714f7d 100644 --- a/src/reloaded-code-serdesai/src/tools/read.rs +++ b/src/reloaded-code-serdesai/src/tools/read.rs @@ -23,6 +23,7 @@ //! [`AllowedPathResolver`]: reloaded_code_core::path::AllowedPathResolver //! [`Tool`]: serdes_ai::tools::Tool +use crate::convert::{core_error_to_serdes, to_serdes_result}; use async_trait::async_trait; use reloaded_code_core::ToolContext; use reloaded_code_core::context::{PathMode, ToolPrompt}; @@ -31,8 +32,6 @@ use reloaded_code_core::tool_metadata::read as read_meta; use reloaded_code_core::tools::{ReadRequest, ReadSettings, read_file}; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult}; -use crate::convert::{core_error_to_serdes, to_serdes_result}; - /// Tool for reading file contents with optional line ranges and numbers. /// /// Generic over any [`PathResolver`] implementation. See the [module-level diff --git a/src/reloaded-code-serdesai/src/tools/todo.rs b/src/reloaded-code-serdesai/src/tools/todo.rs index fd0a7839..0d174da5 100644 --- a/src/reloaded-code-serdesai/src/tools/todo.rs +++ b/src/reloaded-code-serdesai/src/tools/todo.rs @@ -9,6 +9,7 @@ //! - [`create_todo_tools`] - create a linked read/write pair with shared state //! - [`Todo`], [`TodoPriority`], [`TodoStatus`], [`TodoState`] - core types +use crate::convert::{core_error_to_serdes, to_serdes_result}; use async_trait::async_trait; use reloaded_code_core::ToolOutput; use reloaded_code_core::context::{ToolContext, ToolPrompt}; @@ -17,12 +18,16 @@ use reloaded_code_core::tool_metadata::{ }; use reloaded_code_core::tools::{TodoReadRequest, TodoWriteRequest, read_todos, write_todos}; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult, ToolReturn}; - -use crate::convert::{core_error_to_serdes, to_serdes_result}; - // Re-export core types pub use reloaded_code_core::{Todo, TodoPriority, TodoState, TodoStatus}; +/// Tool for reading the current todo list. +#[derive(Debug, Clone)] +pub struct TodoReadTool { + definition: ToolDefinition, + state: TodoState, +} + /// Tool for writing/replacing the todo list. #[derive(Debug, Clone)] pub struct TodoWriteTool { @@ -30,6 +35,16 @@ pub struct TodoWriteTool { state: TodoState, } +impl TodoReadTool { + /// Creates a new todo read tool with the given state. + pub fn new(state: TodoState) -> Self { + Self { + definition: build_todo_read_definition(), + state, + } + } +} + impl TodoWriteTool { /// Creates a new todo write tool with the given state. pub fn new(state: TodoState) -> Self { @@ -41,67 +56,50 @@ impl TodoWriteTool { } #[async_trait] -impl Tool for TodoWriteTool { +impl Tool for TodoReadTool { fn definition(&self) -> ToolDefinition { self.definition.clone() } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { - let args = TodoWriteRequest::parse(args) - .map_err(|e| core_error_to_serdes(todo_write_meta::NAME, e))?; - let result = write_todos(&self.state, args); - to_serdes_result(todo_write_meta::NAME, result.map(ToolOutput::new)) + let args = TodoReadRequest::parse(args) + .map_err(|e| core_error_to_serdes(todo_read_meta::NAME, e))?; + let output = read_todos(&self.state, args); + Ok(ToolReturn::text(output)) } } -impl ToolContext for TodoWriteTool { +impl ToolContext for TodoReadTool { fn name(&self) -> &'static str { - todo_write_meta::NAME + todo_read_meta::NAME } fn context(&self) -> ToolPrompt { - ToolPrompt::TodoWrite - } -} - -/// Tool for reading the current todo list. -#[derive(Debug, Clone)] -pub struct TodoReadTool { - definition: ToolDefinition, - state: TodoState, -} - -impl TodoReadTool { - /// Creates a new todo read tool with the given state. - pub fn new(state: TodoState) -> Self { - Self { - definition: build_todo_read_definition(), - state, - } + ToolPrompt::TodoRead } } #[async_trait] -impl Tool for TodoReadTool { +impl Tool for TodoWriteTool { fn definition(&self) -> ToolDefinition { self.definition.clone() } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { - let args = TodoReadRequest::parse(args) - .map_err(|e| core_error_to_serdes(todo_read_meta::NAME, e))?; - let output = read_todos(&self.state, args); - Ok(ToolReturn::text(output)) + let args = TodoWriteRequest::parse(args) + .map_err(|e| core_error_to_serdes(todo_write_meta::NAME, e))?; + let result = write_todos(&self.state, args); + to_serdes_result(todo_write_meta::NAME, result.map(ToolOutput::new)) } } -impl ToolContext for TodoReadTool { +impl ToolContext for TodoWriteTool { fn name(&self) -> &'static str { - todo_read_meta::NAME + todo_write_meta::NAME } fn context(&self) -> ToolPrompt { - ToolPrompt::TodoRead + ToolPrompt::TodoWrite } } @@ -118,6 +116,18 @@ pub fn create_todo_tools() -> (TodoReadTool, TodoWriteTool, TodoState) { ) } +fn build_todo_read_definition() -> ToolDefinition { + ToolDefinition { + name: todo_read_meta::NAME.to_owned(), + description: todo_read_meta::DESCRIPTION.to_owned(), + parameters_json_schema: SchemaBuilder::new() + .build() + .expect("schema serialization should never fail"), + strict: None, + outer_typed_dict_key: None, + } +} + fn build_todo_write_definition() -> ToolDefinition { ToolDefinition { name: todo_write_meta::NAME.to_owned(), @@ -161,18 +171,6 @@ fn build_todo_write_definition() -> ToolDefinition { } } -fn build_todo_read_definition() -> ToolDefinition { - ToolDefinition { - name: todo_read_meta::NAME.to_owned(), - description: todo_read_meta::DESCRIPTION.to_owned(), - parameters_json_schema: SchemaBuilder::new() - .build() - .expect("schema serialization should never fail"), - strict: None, - outer_typed_dict_key: None, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/src/tools/webfetch.rs b/src/reloaded-code-serdesai/src/tools/webfetch.rs index 48b18d81..9afc606c 100644 --- a/src/reloaded-code-serdesai/src/tools/webfetch.rs +++ b/src/reloaded-code-serdesai/src/tools/webfetch.rs @@ -27,12 +27,6 @@ pub struct WebFetchTool { settings: WebFetchSettings, } -impl Default for WebFetchTool { - fn default() -> Self { - Self::new() - } -} - impl WebFetchTool { /// Creates a new webfetch tool with default client and settings. pub fn new() -> Self { @@ -63,6 +57,12 @@ impl WebFetchTool { } } +impl Default for WebFetchTool { + fn default() -> Self { + Self::new() + } +} + #[async_trait] impl Tool for WebFetchTool { fn definition(&self) -> ToolDefinition { diff --git a/src/reloaded-code-serdesai/src/tools/write.rs b/src/reloaded-code-serdesai/src/tools/write.rs index de1682e6..ecd08023 100644 --- a/src/reloaded-code-serdesai/src/tools/write.rs +++ b/src/reloaded-code-serdesai/src/tools/write.rs @@ -9,6 +9,7 @@ //! //! [`Tool`]: serdes_ai::tools::Tool +use crate::convert::{core_error_to_serdes, to_serdes_result}; use async_trait::async_trait; use reloaded_code_core::context::{PathMode, ToolPrompt}; use reloaded_code_core::path::PathResolver; @@ -17,8 +18,6 @@ use reloaded_code_core::tools::{WriteRequest, WriteSettings, write_file}; use reloaded_code_core::{ToolContext, ToolOutput}; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult}; -use crate::convert::{core_error_to_serdes, to_serdes_result}; - /// Tool for writing content to files. /// /// Generic over any [`PathResolver`] implementation. From 3ef1ba85d5180c3620ac212429a1489e7a0304c1 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 16:50:24 +0100 Subject: [PATCH 2/7] chore: settle rust-llm-tidy convergence (mod decl reorder + README table align) --- README.MD | 16 ++++++++-------- src/reloaded-code-agents/src/lib.rs | 2 +- src/reloaded-code-bubblewrap/src/lib.rs | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.MD b/README.MD index dc5accd7..737775c8 100644 --- a/README.MD +++ b/README.MD @@ -114,14 +114,14 @@ async fn main() -> Result<(), Box> { ## Crate Map -| Crate | Version | Description | -| ------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | -| [**reloaded-code-core**](./src/reloaded-code-core/) | 0.2 | Framework-agnostic tool implementations, path resolvers, permissions, custom tool registry | -| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode], typed catalog, runtime builder | -| [**reloaded-code-serdesai**](./src/reloaded-code-serdesai/) | 0.2 | SerdesAI framework integration, tool adapters, 15 provider bridges, task delegation | -| [**reloaded-code-bubblewrap**](./src/reloaded-code-bubblewrap/) | 0.1 | Linux bubblewrap sandbox profiles (Public Bot + Trusted Maintenance) | -| [**reloaded-code-models-dev**](./src/reloaded-code-models-dev/) | 0.1 | models.dev catalog sync with ETag caching and offline fallback | -| [**reloaded-code-provider-config**](./src/reloaded-code-provider-config/) | 0.1 | Provider configuration loading and provider catalog overrides | +| Crate | Version | Description | +| ------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | +| [**reloaded-code-core**](./src/reloaded-code-core/) | 0.2 | Framework-agnostic tool implementations, path resolvers, permissions, custom tool registry | +| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode], typed catalog, runtime builder | +| [**reloaded-code-serdesai**](./src/reloaded-code-serdesai/) | 0.2 | SerdesAI framework integration, tool adapters, 15 provider bridges, task delegation | +| [**reloaded-code-bubblewrap**](./src/reloaded-code-bubblewrap/) | 0.1 | Linux bubblewrap sandbox profiles (Public Bot + Trusted Maintenance) | +| [**reloaded-code-models-dev**](./src/reloaded-code-models-dev/) | 0.1 | models.dev catalog sync with ETag caching and offline fallback | +| [**reloaded-code-provider-config**](./src/reloaded-code-provider-config/) | 0.1 | Provider configuration loading and provider catalog overrides | ## Examples diff --git a/src/reloaded-code-agents/src/lib.rs b/src/reloaded-code-agents/src/lib.rs index a77dc93d..75af4c3e 100644 --- a/src/reloaded-code-agents/src/lib.rs +++ b/src/reloaded-code-agents/src/lib.rs @@ -22,6 +22,6 @@ mod loader; mod parser; mod path; mod runtime; +mod types; #[cfg(test)] mod test_helpers; -mod types; diff --git a/src/reloaded-code-bubblewrap/src/lib.rs b/src/reloaded-code-bubblewrap/src/lib.rs index 88120410..6467f97e 100644 --- a/src/reloaded-code-bubblewrap/src/lib.rs +++ b/src/reloaded-code-bubblewrap/src/lib.rs @@ -17,6 +17,6 @@ mod error; mod path_util; mod probe; pub mod profile; +pub mod wrap; #[cfg(test)] mod test_helpers; -pub mod wrap; From 5735fb1aa41f7459a5d28dcb19d4bd1276ae135b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 16:54:54 +0100 Subject: [PATCH 3/7] docs: add missing doc comments, # Arguments/# Errors sections; resolve DOC001/DOC004/DOC006 Fixes all 82 rust-llm-tidy lint findings across the workspace via parallel subagents; also settles remaining mod-declaration reorder convergence. --- src/reloaded-code-agents/src/runtime/model.rs | 9 +++++ src/reloaded-code-agents/src/types/config.rs | 5 +++ .../src/wrap/blocking.rs | 6 ++++ .../src/wrap/command.rs | 6 ++++ .../src/wrap/tokio.rs | 6 ++++ .../benches/common/corpus_medium.rs | 6 ++++ .../benches/common/corpus_small.rs | 4 ++- src/reloaded-code-core/benches/common/mod.rs | 14 ++++++++ .../examples/system_prompt/build.rs | 4 +++ .../examples/system_prompt/report.rs | 29 ++++++++++++++++ .../src/context/tool_prompt/mod.rs | 6 ++++ src/reloaded-code-core/src/custom_tool/mod.rs | 2 +- .../src/fs/blocking_impl.rs | 14 ++++++++ src/reloaded-code-core/src/fs/tokio_impl.rs | 14 ++++++++ .../src/models/catalog/internal/hash_utils.rs | 34 +++++++++++++++++++ .../src/models/catalog/public/entry.rs | 3 ++ .../src/path/allowed_glob/normalize.rs | 3 ++ .../src/tools/bash/blocking_impl.rs | 7 ++++ .../src/tools/bash/tokio_impl.rs | 7 ++++ src/reloaded-code-core/src/tools/edit.rs | 6 ++++ src/reloaded-code-core/src/tools/grep.rs | 6 ++++ src/reloaded-code-core/src/tools/todo.rs | 23 +++++++++---- .../src/tools/webfetch/blocking_impl.rs | 6 ++++ .../src/tools/webfetch/mod.rs | 10 ++++++ .../src/tools/webfetch/tokio_impl.rs | 6 ++++ src/reloaded-code-core/src/tools/write.rs | 5 +++ .../src/api/schema.rs | 5 +++ .../src/catalog/test_utils.rs | 16 +++++++++ .../src/api_type.rs | 4 +++ src/reloaded-code-serdesai/src/convert.rs | 9 +++++ src/reloaded-code-serdesai/src/mock.rs | 7 ++++ src/reloaded-code-serdesai/src/tools/todo.rs | 6 ++-- 32 files changed, 276 insertions(+), 12 deletions(-) diff --git a/src/reloaded-code-agents/src/runtime/model.rs b/src/reloaded-code-agents/src/runtime/model.rs index 3cc56595..ad9fbb97 100644 --- a/src/reloaded-code-agents/src/runtime/model.rs +++ b/src/reloaded-code-agents/src/runtime/model.rs @@ -157,6 +157,15 @@ impl std::error::Error for ModelResolutionError {} /// # Returns /// /// A [`ResolvedModel`] on success, or a [`ModelResolutionError`] if something's wrong. +/// +/// # Errors +/// +/// Returns [`ModelResolutionError::MalformedModelIdentifier`] when the agent or +/// runtime default model is not in `provider/model-id` form, +/// [`ModelResolutionError::MissingEffectiveModel`] when neither source sets a +/// model, [`ModelResolutionError::UnknownProvider`] when the provider is not in +/// the catalog, or [`ModelResolutionError::UnknownModel`] when the model is not +/// in the catalog for that provider. pub fn resolve_model_with_catalog( catalog: &ModelCatalog, defaults: &super::state::AgentDefaults, diff --git a/src/reloaded-code-agents/src/types/config.rs b/src/reloaded-code-agents/src/types/config.rs index f10f9630..4fdacde4 100644 --- a/src/reloaded-code-agents/src/types/config.rs +++ b/src/reloaded-code-agents/src/types/config.rs @@ -193,6 +193,11 @@ impl Default for PermissionRule { /// Parses a model identifier string into `(provider, model)` parts. /// +/// # Arguments +/// +/// - `value` - the model identifier string to parse, expected in `"provider/model-id"` +/// form (e.g., `"openai/gpt-4"`, `"synthetic/hf:moonshotai/Kimi-K2.5"`). +/// /// ## Expected Format /// `"provider/model-id"` (e.g., `"openai/gpt-4"`, `"synthetic/hf:moonshotai/Kimi-K2.5"`). /// diff --git a/src/reloaded-code-bubblewrap/src/wrap/blocking.rs b/src/reloaded-code-bubblewrap/src/wrap/blocking.rs index c60bf8fb..5fa60137 100644 --- a/src/reloaded-code-bubblewrap/src/wrap/blocking.rs +++ b/src/reloaded-code-bubblewrap/src/wrap/blocking.rs @@ -12,6 +12,12 @@ use std::process::Stdio; /// Builds a sync [`CommandWrap`] from a [`Profile`]. /// +/// # Arguments +/// +/// - `profile` - the validated sandbox profile to wrap the command in +/// - `command` - the shell command string to run inside the sandbox +/// - `workdir` - optional host working directory for the command +/// /// # Errors /// - Returns [`LinuxBwrapError::InvalidPath`] when `workdir` is not an absolute path, /// does not exist, is not a directory, or is not visible inside the sandbox. diff --git a/src/reloaded-code-bubblewrap/src/wrap/command.rs b/src/reloaded-code-bubblewrap/src/wrap/command.rs index 918a180b..90c0e64a 100644 --- a/src/reloaded-code-bubblewrap/src/wrap/command.rs +++ b/src/reloaded-code-bubblewrap/src/wrap/command.rs @@ -60,6 +60,12 @@ impl<'a> LinuxBwrapWrappedCommand<'a> { /// Builds a `bwrap` command line that runs `command` inside the sandbox /// described by `profile`. /// +/// # Arguments +/// +/// - `profile` - the validated sandbox profile used to build the `bwrap` command line +/// - `command` - the shell command string to run inside the sandbox +/// - `workdir` - optional host working directory for the command +/// /// # Errors /// - Returns [`LinuxBwrapError::InvalidPath`] when `workdir` is not an absolute path. /// - Returns [`LinuxBwrapError::InvalidPath`] when `workdir` does not exist or is not a directory. diff --git a/src/reloaded-code-bubblewrap/src/wrap/tokio.rs b/src/reloaded-code-bubblewrap/src/wrap/tokio.rs index 1b268914..c1749019 100644 --- a/src/reloaded-code-bubblewrap/src/wrap/tokio.rs +++ b/src/reloaded-code-bubblewrap/src/wrap/tokio.rs @@ -12,6 +12,12 @@ use std::process::Stdio; /// Builds an async [`CommandWrap`] from a [`Profile`]. /// +/// # Arguments +/// +/// - `profile` - the validated sandbox profile to wrap the command in +/// - `command` - the shell command string to run inside the sandbox +/// - `workdir` - optional host working directory for the command +/// /// # Errors /// - Returns [`LinuxBwrapError::InvalidPath`] when `workdir` is not an absolute path, /// does not exist, is not a directory, or is not visible inside the sandbox. diff --git a/src/reloaded-code-core/benches/common/corpus_medium.rs b/src/reloaded-code-core/benches/common/corpus_medium.rs index aaee15dc..0a36e821 100644 --- a/src/reloaded-code-core/benches/common/corpus_medium.rs +++ b/src/reloaded-code-core/benches/common/corpus_medium.rs @@ -29,6 +29,7 @@ pub(crate) struct OutgoingMessageSender { request_id_to_callback: Mutex>>, } +/// Parameters carried by an outgoing notification to the client. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingNotificationParams { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -49,18 +50,21 @@ pub(crate) enum OutgoingMessage { // Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta // MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta // Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts +/// Additional MCP-specific data attached to a notification's `_meta` parameter. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct OutgoingNotificationMeta { pub request_id: Option, } +/// An error response sent from the server to the client. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingError { pub error: JSONRPCErrorError, pub id: RequestId, } +/// A notification message sent from the server to the client. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingNotification { pub method: String, @@ -68,6 +72,7 @@ pub(crate) struct OutgoingNotification { pub params: Option, } +/// A request message sent from the server to the client. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingRequest { pub id: RequestId, @@ -76,6 +81,7 @@ pub(crate) struct OutgoingRequest { pub params: Option, } +/// A response message sent from the server to the client. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingResponse { pub id: RequestId, diff --git a/src/reloaded-code-core/benches/common/corpus_small.rs b/src/reloaded-code-core/benches/common/corpus_small.rs index 1a409c0d..28e1fa28 100644 --- a/src/reloaded-code-core/benches/common/corpus_small.rs +++ b/src/reloaded-code-core/benches/common/corpus_small.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::EventMsg; use std::collections::BTreeMap; use std::sync::LazyLock; +/// Static tool specification for the `update_plan` tool. pub static PLAN_TOOL: LazyLock = LazyLock::new(|| { let mut plan_item_props = BTreeMap::new(); plan_item_props.insert("step".to_string(), JsonSchema::String { description: None }); @@ -61,6 +62,7 @@ At most one step can be in_progress at a time. }) }); +/// Handles `update_plan` tool invocations by recording the model's plan. pub struct PlanHandler; #[async_trait] @@ -100,7 +102,7 @@ impl ToolHandler for PlanHandler { /// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render. /// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other -/// than forcing it to come up and document a plan (TBD how that affects performance). +/// than forcing it to come up and document a plan; the parsed plan is forwarded to the session as a `PlanUpdate` event for clients to consume. pub(crate) async fn handle_update_plan( session: &Session, turn_context: &TurnContext, diff --git a/src/reloaded-code-core/benches/common/mod.rs b/src/reloaded-code-core/benches/common/mod.rs index fbed770a..e25387b6 100644 --- a/src/reloaded-code-core/benches/common/mod.rs +++ b/src/reloaded-code-core/benches/common/mod.rs @@ -33,18 +33,32 @@ const CORPUS_LARGE_RAW: &str = include_str!("corpus_large.rs"); const CORPUS_MEDIUM_RAW: &str = include_str!("corpus_medium.rs"); const CORPUS_SMALL_RAW: &str = include_str!("corpus_small.rs"); +/// Selects which benchmark corpus to load. #[derive(Clone, Copy)] pub enum CorpusSize { + /// Small corpus (plan handler). Small, + /// Medium corpus (outgoing message handling). Medium, + /// Large corpus (session manager). Large, } #[allow(dead_code)] // Used by some benchmarks but not all +/// Returns the requested corpus with `\n` line endings replaced by CRLF. +/// +/// # Arguments +/// +/// - `size`: the corpus to select. pub fn corpus_crlf(size: CorpusSize) -> String { corpus_content(size).replace('\n', "\r\n") } +/// Returns the raw source text for the requested corpus. +/// +/// # Arguments +/// +/// - `size`: the corpus to select. pub fn corpus_content(size: CorpusSize) -> &'static str { match size { CorpusSize::Small => CORPUS_SMALL_RAW, diff --git a/src/reloaded-code-core/examples/system_prompt/build.rs b/src/reloaded-code-core/examples/system_prompt/build.rs index afc3a93a..b3b8b732 100644 --- a/src/reloaded-code-core/examples/system_prompt/build.rs +++ b/src/reloaded-code-core/examples/system_prompt/build.rs @@ -3,6 +3,10 @@ use reloaded_code_core::context; use reloaded_code_core::{AllowedPathResolver, SystemPromptBuilder}; /// Renders one example case and its matching tool-definition payload. +/// +/// # Arguments +/// +/// - `case`: the example case to render. pub fn build_case(case: PromptCase) -> PromptArtifacts { let system_prompt = build_system_prompt(case); let tool_definitions = definitions::tool_definitions_for_case(case); diff --git a/src/reloaded-code-core/examples/system_prompt/report.rs b/src/reloaded-code-core/examples/system_prompt/report.rs index 7167801b..bdda408c 100644 --- a/src/reloaded-code-core/examples/system_prompt/report.rs +++ b/src/reloaded-code-core/examples/system_prompt/report.rs @@ -1,11 +1,20 @@ use super::{sort_sizes_desc, PromptArtifacts}; /// Approximates token count from character count. +/// +/// # Arguments +/// +/// - `chars`: the number of characters to estimate tokens for. pub fn estimate_tokens(chars: usize) -> usize { chars.div_ceil(4) } /// Prints the total static request footprint for one example case. +/// +/// # Arguments +/// +/// - `label`: the label to print for the case. +/// - `artifacts`: the rendered prompt artifacts to measure. pub fn print_footprint(label: &str, artifacts: &PromptArtifacts) { println!("{label}:"); println!( @@ -28,6 +37,11 @@ pub fn print_footprint(label: &str, artifacts: &PromptArtifacts) { } /// Prints a sorted size breakdown. +/// +/// # Arguments +/// +/// - `title`: the heading to print above the breakdown. +/// - `sizes`: `(name, chars)` entries to print, already sorted. pub fn print_ranked_sizes(title: &str, sizes: &[(String, usize)]) { println!("\n{title}"); for (name, chars) in sizes { @@ -38,6 +52,11 @@ pub fn print_ranked_sizes(title: &str, sizes: &[(String, usize)]) { } } +/// Prints the pretty-printed tool definitions for one example case. +/// +/// # Arguments +/// +/// - `artifacts`: the rendered prompt artifacts whose tool definitions to print. pub fn print_tool_definitions(artifacts: &super::PromptArtifacts) { println!("\n{}", "=".repeat(60)); println!("Tool Definitions:"); @@ -49,12 +68,22 @@ pub fn print_tool_definitions(artifacts: &super::PromptArtifacts) { } /// Returns rendered tool-guideline section sizes sorted from largest to smallest. +/// +/// # Arguments +/// +/// - `artifacts`: the rendered prompt artifacts to measure. pub fn section_sizes(artifacts: &PromptArtifacts) -> Vec<(String, usize)> { let mut sections = artifacts.guideline_sections.clone(); sort_sizes_desc(&mut sections); sections } +/// Collects `## Tool` sections from a rendered guideline prompt as +/// `(name, byte length)` pairs. +/// +/// # Arguments +/// +/// - `prompt`: the rendered system prompt to scan for guideline sections. pub(super) fn collect_guideline_sections(prompt: &str) -> Vec<(String, usize)> { let mut in_guidelines = false; let mut current_name: Option = None; diff --git a/src/reloaded-code-core/src/context/tool_prompt/mod.rs b/src/reloaded-code-core/src/context/tool_prompt/mod.rs index c7a07a19..ef842989 100644 --- a/src/reloaded-code-core/src/context/tool_prompt/mod.rs +++ b/src/reloaded-code-core/src/context/tool_prompt/mod.rs @@ -165,15 +165,21 @@ impl ToolPromptFacts { } } +/// Appends a text block to `output` without adding a trailing newline. pub(super) fn push_block(output: &mut String, block: &str) { output.push_str(block); } +/// Appends a text line followed by a trailing newline to `output`. pub(super) fn push_line(output: &mut String, line: &str) { output.push_str(line); output.push('\n'); } +/// Writes a human-readable list of tool names into `output`. +/// +/// Renders zero, one, two, or many tools, joining items with commas and "and" +/// as appropriate. pub(super) fn write_tool_list(output: &mut String, tools: &[&str]) { match tools { [] => {} diff --git a/src/reloaded-code-core/src/custom_tool/mod.rs b/src/reloaded-code-core/src/custom_tool/mod.rs index 13b5ee71..e26fda7a 100644 --- a/src/reloaded-code-core/src/custom_tool/mod.rs +++ b/src/reloaded-code-core/src/custom_tool/mod.rs @@ -88,9 +88,9 @@ pub(crate) mod definition; pub(crate) mod factory; pub(crate) mod registry; pub(crate) mod runtime; +pub(crate) mod tool; #[cfg(test)] pub(crate) mod test_stubs; -pub(crate) mod tool; #[cfg(test)] mod tests { use super::test_stubs::{EchoFactory, TestFactory}; diff --git a/src/reloaded-code-core/src/fs/blocking_impl.rs b/src/reloaded-code-core/src/fs/blocking_impl.rs index 63dd6527..49286f4c 100644 --- a/src/reloaded-code-core/src/fs/blocking_impl.rs +++ b/src/reloaded-code-core/src/fs/blocking_impl.rs @@ -5,6 +5,9 @@ use std::path::Path; /// Creates a directory and all parent directories. /// +/// # Arguments +/// - `path`: The directory path to create, including any missing parent directories. +/// /// # Errors /// - Returns [`ToolError::Io`] when the directory cannot be created (e.g., permission /// denied or other I/O error). @@ -16,6 +19,10 @@ pub fn create_dir_all(path: impl AsRef) -> ToolResult<()> { /// Opens a file for buffered reading. /// +/// # Arguments +/// - `path`: The path of the file to open for buffered reading. +/// - `capacity`: The buffer capacity in bytes. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be opened (e.g., file does not exist, /// permission denied, or other I/O error). @@ -31,6 +38,9 @@ pub fn open_buffered( /// Reads a file to string. /// +/// # Arguments +/// - `path`: The path of the file to read. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, /// permission denied, or other I/O error). @@ -42,6 +52,10 @@ pub fn read_to_string(path: impl AsRef) -> ToolResult { /// Writes content to a file. /// +/// # Arguments +/// - `path`: The path of the file to write to. +/// - `contents`: The bytes to write to the file. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory /// does not exist, permission denied, or other I/O error). diff --git a/src/reloaded-code-core/src/fs/tokio_impl.rs b/src/reloaded-code-core/src/fs/tokio_impl.rs index cafa8e6e..ac3ed450 100644 --- a/src/reloaded-code-core/src/fs/tokio_impl.rs +++ b/src/reloaded-code-core/src/fs/tokio_impl.rs @@ -5,6 +5,9 @@ use std::path::Path; /// Creates a directory and all parent directories. /// +/// # Arguments +/// - `path`: The directory path to create, including any missing parent directories. +/// /// # Errors /// - Returns [`ToolError::Io`] when the directory cannot be created (e.g., permission /// denied or other I/O error). @@ -16,6 +19,10 @@ pub async fn create_dir_all(path: impl AsRef) -> ToolResult<()> { /// Opens a file for buffered reading. /// +/// # Arguments +/// - `path`: The path of the file to open for buffered reading. +/// - `capacity`: The buffer capacity in bytes. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be opened (e.g., file does not exist, /// permission denied, or other I/O error). @@ -31,6 +38,9 @@ pub async fn open_buffered( /// Reads a file to string. /// +/// # Arguments +/// - `path`: The path of the file to read. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be read (e.g., file does not exist, /// permission denied, or other I/O error). @@ -42,6 +52,10 @@ pub async fn read_to_string(path: impl AsRef) -> ToolResult { /// Writes content to a file. /// +/// # Arguments +/// - `path`: The path of the file to write to. +/// - `contents`: The bytes to write to the file. +/// /// # Errors /// - Returns [`ToolError::Io`] when the file cannot be written (e.g., parent directory /// does not exist, permission denied, or other I/O error). diff --git a/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs b/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs index c35e1da4..ecd46f96 100644 --- a/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs +++ b/src/reloaded-code-core/src/models/catalog/internal/hash_utils.rs @@ -4,11 +4,27 @@ use crate::internal::hash64::Hash64; use ahash::RandomState; use core::hash::{BuildHasher, Hasher}; +/// Hashes a provider key into a [`Hash64`] using the given hash state. +/// +/// # Arguments +/// +/// - `hash_state`: Hash state used to derive the hash function. +/// - `provider_key`: Raw provider key string to hash. #[inline(always)] pub fn hash_provider_key(hash_state: &RandomState, provider_key: &str) -> Hash64 { Hash64::from_u64(hash_state.hash_one(provider_key.as_bytes())) } +/// Hashes a provider key and model key pair into a [`Hash64`]. +/// +/// The two keys are written into the hasher with a `0xFF` separator byte +/// to prevent ambiguous collisions across concatenation boundaries. +/// +/// # Arguments +/// +/// - `hash_state`: Hash state used to build the hasher. +/// - `provider_key`: Provider key written first into the hash. +/// - `model_key`: Model key written after the separator byte. #[inline(always)] pub fn hash_provider_model_key( hash_state: &RandomState, @@ -22,6 +38,14 @@ pub fn hash_provider_model_key( Hash64::from_u64(hasher.finish()) } +/// Creates an independent [`RandomState`] derived from a seed. +/// +/// Using ahash's `generate_with` mixes the seed with internal entropy, so +/// each call produces a different hash function even for the same seed. +/// +/// # Arguments +/// +/// - `seed`: Seed value mixed into the generated hash state. #[inline(always)] pub fn hash_state_for_seed(seed: u8) -> RandomState { // Using ahash's generate_with() creates an independent hash function @@ -30,11 +54,21 @@ pub fn hash_state_for_seed(seed: u8) -> RandomState { RandomState::generate_with(u64::from(seed), 0, 0, 0) } +/// Returns the truncated 48-bit hash stored in a packed provider-model table entry. +/// +/// # Arguments +/// +/// - `entry`: Packed provider-model table entry whose stored hash is returned. #[inline(always)] pub fn provider_model_table_entry_hash(entry: &super::PackedProviderModelTableEntry) -> u64 { entry.hash48() } +/// Returns the truncated 48-bit hash stored in a packed provider table entry. +/// +/// # Arguments +/// +/// - `entry`: Packed provider table entry whose stored hash is returned. #[inline(always)] pub fn provider_table_entry_hash(entry: &super::PackedProviderTableEntry) -> u64 { entry.hash48() diff --git a/src/reloaded-code-core/src/models/catalog/public/entry.rs b/src/reloaded-code-core/src/models/catalog/public/entry.rs index 4c5e6b94..fe9883af 100644 --- a/src/reloaded-code-core/src/models/catalog/public/entry.rs +++ b/src/reloaded-code-core/src/models/catalog/public/entry.rs @@ -16,6 +16,8 @@ use crate::models::catalog::internal::Fixed4; use crate::models::ProviderType; use tinyvec::TinyVec; +/// Number of environment variable strings inlined inline (without heap +/// allocation) into a [`ProviderEnvVars`] value. pub(crate) const INLINE_PROVIDER_ENV_VARS: usize = 2; /// Model lookup result. @@ -46,6 +48,7 @@ pub struct Provider<'a> { pub api_type: ProviderType, } +/// Candidate environment variables used to resolve a provider's API key. pub(crate) type ProviderEnvVars<'a> = TinyVec<[&'a str; INLINE_PROVIDER_ENV_VARS]>; impl Model { diff --git a/src/reloaded-code-core/src/path/allowed_glob/normalize.rs b/src/reloaded-code-core/src/path/allowed_glob/normalize.rs index d191fb3e..f8e8f43d 100644 --- a/src/reloaded-code-core/src/path/allowed_glob/normalize.rs +++ b/src/reloaded-code-core/src/path/allowed_glob/normalize.rs @@ -9,6 +9,9 @@ use std::path::{Path, PathBuf}; /// Wraps the internal expansion logic with fail-fast error handling: returns /// `ToolError::InvalidPath` if expansion fails (e.g., unset variable). /// +/// # Arguments +/// - `path`: The path pattern string to expand (e.g., `~/foo`, `$VAR/bar`). +/// /// # Errors /// - Returns [`ToolError::InvalidPath`] when shell expansion fails (e.g., unset /// environment variable in the path pattern). diff --git a/src/reloaded-code-core/src/tools/bash/blocking_impl.rs b/src/reloaded-code-core/src/tools/bash/blocking_impl.rs index 8dddad57..a9fb975d 100644 --- a/src/reloaded-code-core/src/tools/bash/blocking_impl.rs +++ b/src/reloaded-code-core/src/tools/bash/blocking_impl.rs @@ -34,6 +34,13 @@ enum WaitOutcome { /// - Windows: Job Objects /// - Unix: Process groups /// +/// # Arguments +/// - `mode`: The execution mode (host or Linux sandbox). +/// - `request`: The bash request carrying the command, optional working directory, and +/// optional timeout. +/// - `settings`: The bash settings providing permission checks, default working directory, +/// and timeout limits. +/// /// # Errors /// - Returns [`ToolError::PermissionDenied`] when the command is blocked by `settings.permission`. /// - Returns `ToolError::Validation` if timeout is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs index fda67f29..aafbf1a6 100644 --- a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs +++ b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs @@ -38,6 +38,13 @@ type SharedPipeBuffer = Arc>>; /// - Windows: Job Objects /// - Unix: Process groups /// +/// # Arguments +/// - `mode`: The execution mode (host or Linux sandbox). +/// - `request`: The bash request carrying the command, optional working directory, and +/// optional timeout. +/// - `settings`: The bash settings providing permission checks, default working directory, +/// and timeout limits. +/// /// # Errors /// - Returns [`ToolError::PermissionDenied`] when the command is blocked by `settings.permission`. /// - Returns `ToolError::Validation` if timeout is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/edit.rs b/src/reloaded-code-core/src/tools/edit.rs index 6c6e3c72..dcd05024 100644 --- a/src/reloaded-code-core/src/tools/edit.rs +++ b/src/reloaded-code-core/src/tools/edit.rs @@ -100,6 +100,12 @@ impl From for ToolError { /// /// Returns success message with replacement count. /// +/// # Arguments +/// - `resolver`: [`PathResolver`] used to resolve `request.file_path` to a filesystem path. +/// - `request`: [`EditRequest`] carrying the file path, `old_string`, `new_string`, and +/// the `replace_all` flag. +/// - `_settings`: [`EditSettings`] (currently unused). +/// /// # Errors /// - Returns [`EditError::EmptyOldString`] when `request.old_string` is empty. /// - Returns [`EditError::IdenticalStrings`] when `old_string` and `new_string` diff --git a/src/reloaded-code-core/src/tools/grep.rs b/src/reloaded-code-core/src/tools/grep.rs index 33d853d4..92e99fac 100644 --- a/src/reloaded-code-core/src/tools/grep.rs +++ b/src/reloaded-code-core/src/tools/grep.rs @@ -287,6 +287,12 @@ impl Default for GrepSettings { /// /// Results are sorted by modification time (newest first). /// Binary files are automatically skipped. +/// +/// # Arguments +/// - `resolver`: [`PathResolver`] used to resolve `request.path` to an absolute directory. +/// - `request`: [`GrepRequest`] carrying the regex pattern, search path, optional filename +/// filter, and optional result limit. +/// - `settings`: [`GrepSettings`] providing the maximum result limit. pub fn grep_search( resolver: &R, request: GrepRequest, diff --git a/src/reloaded-code-core/src/tools/todo.rs b/src/reloaded-code-core/src/tools/todo.rs index 2b871fc3..592154fc 100644 --- a/src/reloaded-code-core/src/tools/todo.rs +++ b/src/reloaded-code-core/src/tools/todo.rs @@ -8,17 +8,17 @@ use serde_json::Value; use std::fmt::Write; use std::sync::Arc; -/// Serde-friendly todo-read request owned by the core crate. +/// Serde-friendly request for reading the task list, owned by the core crate. #[derive(Debug, Clone, Deserialize)] pub struct TodoReadRequest {} -/// Thread-safe shared state for todo list. +/// Thread-safe shared state holding the tracked task list. #[derive(Debug, Clone, Default)] pub struct TodoState { todos: Arc>>, } -/// Serde-friendly todo-write request owned by the core crate. +/// Serde-friendly request for replacing the task list, owned by the core crate. #[derive(Debug, Clone, Deserialize)] pub struct TodoWriteRequest { /// The complete list of todos to set. @@ -108,7 +108,12 @@ impl TodoStatus { } } -/// Reads and formats the current todo list. +/// Reads and formats the current list of tracked tasks. +/// +/// # Arguments +/// - `state`: Shared state holding the task list to read. +/// - `_request`: Placeholder request required by the tool dispatch signature; +/// it carries no fields. pub fn read_todos(state: &TodoState, _request: TodoReadRequest) -> String { let todos = state.todos.read(); @@ -132,13 +137,17 @@ pub fn read_todos(state: &TodoState, _request: TodoReadRequest) -> String { output } -/// Writes/replaces the todo list with new items. +/// Replaces the current task list with new items. /// /// Validates that all todos have non-empty id and content. /// +/// # Arguments +/// - `state`: Shared state whose task list is replaced by the new items. +/// - `request`: Request carrying the new list of todos to store. +/// /// # Errors -/// - Returns [`ToolError::Validation`] when any todo has an empty or whitespace-only `id`. -/// - Returns [`ToolError::Validation`] when any todo has empty or whitespace-only `content`. +/// - Returns [`ToolError::Validation`] when any task has an empty or whitespace-only `id`. +/// - Returns [`ToolError::Validation`] when any task has empty or whitespace-only `content`. pub fn write_todos(state: &TodoState, request: TodoWriteRequest) -> ToolResult { for todo in &request.todos { if todo.id.trim().is_empty() { diff --git a/src/reloaded-code-core/src/tools/webfetch/blocking_impl.rs b/src/reloaded-code-core/src/tools/webfetch/blocking_impl.rs index 19a1018a..31515456 100644 --- a/src/reloaded-code-core/src/tools/webfetch/blocking_impl.rs +++ b/src/reloaded-code-core/src/tools/webfetch/blocking_impl.rs @@ -12,6 +12,12 @@ use std::time::Duration; /// - Other content types returned as-is /// - Response size is limited to `max_response_size` bytes /// +/// # Arguments +/// - `client`: Blocking HTTP client used to perform the request. +/// - `request`: The fetch request containing the URL and optional timeout. +/// - `settings`: Runtime settings that determine the default timeout and the +/// maximum allowed timeout and response size. +/// /// # Errors /// /// Returns `ToolError::Validation` if timeout_ms is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/webfetch/mod.rs b/src/reloaded-code-core/src/tools/webfetch/mod.rs index ce802b9e..34b3ceb5 100644 --- a/src/reloaded-code-core/src/tools/webfetch/mod.rs +++ b/src/reloaded-code-core/src/tools/webfetch/mod.rs @@ -173,6 +173,11 @@ impl Default for WebFetchSettings { } /// Formats JSON content for readability. +/// +/// Invalid JSON is returned unchanged. +/// +/// # Arguments +/// - `json_str`: JSON text to pretty-print. pub fn format_json(json_str: &str) -> String { match serde_json::from_str::(json_str) { Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), @@ -181,6 +186,11 @@ pub fn format_json(json_str: &str) -> String { } /// Converts HTML to markdown for LLM-friendly output. +/// +/// Unparseable input is returned unchanged. +/// +/// # Arguments +/// - `html`: HTML source to convert. pub fn html_to_markdown(html: &str) -> String { let options = ConversionOptions { preprocessing: PreprocessingOptions { diff --git a/src/reloaded-code-core/src/tools/webfetch/tokio_impl.rs b/src/reloaded-code-core/src/tools/webfetch/tokio_impl.rs index b974ea99..f0f5fb11 100644 --- a/src/reloaded-code-core/src/tools/webfetch/tokio_impl.rs +++ b/src/reloaded-code-core/src/tools/webfetch/tokio_impl.rs @@ -11,6 +11,12 @@ use std::time::Duration; /// - Other content types returned as-is /// - Response size is limited to `max_response_size` bytes /// +/// # Arguments +/// - `client`: Async HTTP client used to perform the request. +/// - `request`: The fetch request containing the URL and optional timeout. +/// - `settings`: Runtime settings that determine the default timeout and the +/// maximum allowed timeout and response size. +/// /// # Errors /// /// Returns `ToolError::Validation` if timeout_ms is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/write.rs b/src/reloaded-code-core/src/tools/write.rs index 5310bf6a..8cfdd7e5 100644 --- a/src/reloaded-code-core/src/tools/write.rs +++ b/src/reloaded-code-core/src/tools/write.rs @@ -43,6 +43,11 @@ impl WriteSettings { /// /// Overwrites existing files. Returns a success message with byte count. /// +/// # Arguments +/// - `resolver`: Resolves `request.file_path` to an absolute path. +/// - `request`: The write request containing the target path and content. +/// - `_settings`: Runtime settings; currently unused. +/// /// # Errors /// - Returns [`ToolError::InvalidPath`] when `resolver.resolve()` fails to /// resolve `request.file_path` (e.g., path is not absolute or violates policy). diff --git a/src/reloaded-code-models-dev/src/api/schema.rs b/src/reloaded-code-models-dev/src/api/schema.rs index 52e3167d..d0be856a 100644 --- a/src/reloaded-code-models-dev/src/api/schema.rs +++ b/src/reloaded-code-models-dev/src/api/schema.rs @@ -44,6 +44,8 @@ use crate::error::CatalogResult; use serde::Deserialize; use std::collections::HashMap; +/// A provider entry from the models.dev API: `npm` package, API base URL, env +/// var names, and the provider's model map. #[derive(Debug, Deserialize)] pub(crate) struct ApiProviderEntry { #[serde(default)] @@ -56,6 +58,7 @@ pub(crate) struct ApiProviderEntry { pub(crate) models: HashMap, } +/// A model entry with optional token limits and directional modalities. #[derive(Debug, Deserialize)] pub(crate) struct ApiModelEntry { #[serde(default)] @@ -64,6 +67,7 @@ pub(crate) struct ApiModelEntry { pub(crate) modalities: Option, } +/// Token limits for a model: `context`, `input`, and `output`. #[derive(Debug, Deserialize)] pub(crate) struct ApiModelLimit { #[serde(default)] @@ -74,6 +78,7 @@ pub(crate) struct ApiModelLimit { pub(crate) output: u32, } +/// Directional modality lists: supported `input` and `output` modalities. #[derive(Debug, Deserialize)] pub(crate) struct ApiModelModalities { #[serde(default)] diff --git a/src/reloaded-code-models-dev/src/catalog/test_utils.rs b/src/reloaded-code-models-dev/src/catalog/test_utils.rs index a7fec883..a773fbd2 100644 --- a/src/reloaded-code-models-dev/src/catalog/test_utils.rs +++ b/src/reloaded-code-models-dev/src/catalog/test_utils.rs @@ -1,5 +1,12 @@ use std::io::{BufRead, Write}; +/// A response for the mock HTTP server to serve to the fetch request. +/// +/// Variants exercise the different `fetch_catalog` outcomes: +/// - `Ok` - a full 200 response with an ETag and body +/// - `PartialOk` - a 200 whose declared `Content-Length` differs from the body +/// - `NotModified` - a 304 echoing the provided ETag +/// - `Status` - an arbitrary status code with a reason phrase pub enum MockResponse { Ok { etag: &'static str, @@ -19,6 +26,8 @@ pub enum MockResponse { }, } +/// Sample `api.json` payload bytes matching the models.dev schema, used as the +/// body for mock catalog responses. pub fn sample_api_json() -> &'static [u8] { br#" { @@ -45,6 +54,13 @@ pub fn sample_api_json() -> &'static [u8] { "# } +/// Starts a threaded mock HTTP server that serves a single request for `/api.json`. +/// +/// Returns the server thread's join handle and the base URL to fetch from. +/// +/// # Arguments +/// +/// - `response` - the [`MockResponse`] the server should send. pub fn start_mock_server(response: MockResponse) -> (std::thread::JoinHandle<()>, String) { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().unwrap().port(); diff --git a/src/reloaded-code-provider-config/src/api_type.rs b/src/reloaded-code-provider-config/src/api_type.rs index 9a39a7c2..c0311db9 100644 --- a/src/reloaded-code-provider-config/src/api_type.rs +++ b/src/reloaded-code-provider-config/src/api_type.rs @@ -12,6 +12,10 @@ pub const DEFAULT_API_TYPE: &str = "openai-compatible"; /// OpenAI-API-compatible endpoint. /// /// Returns [`ProviderType::Unknown`] for unrecognized strings. +/// +/// # Arguments +/// +/// - `s` - the YAML `api_type` string to map. pub fn api_type_from_str(s: &str) -> ProviderType { match s { "openai" | "openai-compatible" => ProviderType::OpenAiCompletions, diff --git a/src/reloaded-code-serdesai/src/convert.rs b/src/reloaded-code-serdesai/src/convert.rs index 200af44e..80af3b81 100644 --- a/src/reloaded-code-serdesai/src/convert.rs +++ b/src/reloaded-code-serdesai/src/convert.rs @@ -16,6 +16,10 @@ use serdes_ai::tools::{ToolDefinition, ToolError as SerdesError, ToolReturn}; /// Fields map 1:1. The SerdesAI `outer_typed_dict_key` field is always `None` /// because portable definitions do not carry framework-specific metadata. /// +/// # Arguments +/// +/// - `definition` - the portable core tool definition to convert. +/// /// # Example /// /// ``` @@ -48,6 +52,11 @@ pub fn custom_definition_to_serdes(definition: CustomToolDefinition) -> ToolDefi /// This is the primary conversion function for tool implementations. /// Requires tool_name for proper error context in validation errors. /// +/// # Arguments +/// +/// - `tool_name` - the name of the tool, used for error context in validation errors. +/// - `result` - the core tool result to convert. +/// /// # Example /// /// ```no_run diff --git a/src/reloaded-code-serdesai/src/mock.rs b/src/reloaded-code-serdesai/src/mock.rs index ca4c4986..4ebbc4f2 100644 --- a/src/reloaded-code-serdesai/src/mock.rs +++ b/src/reloaded-code-serdesai/src/mock.rs @@ -125,6 +125,13 @@ impl ModelTrait for Streamed { /// The second-turn response includes whatever the real tool returned, so /// the output reflects actual tool execution rather than a canned message. /// +/// # Arguments +/// +/// - `tool_name` - name of the tool to call on the first turn. +/// - `args` - JSON arguments passed to the tool call on the first turn. +/// - `fallback_text` - text prefix for the second-turn response; the real tool +/// return is appended after it. +/// /// # Example /// /// ```rust,no_run diff --git a/src/reloaded-code-serdesai/src/tools/todo.rs b/src/reloaded-code-serdesai/src/tools/todo.rs index 0d174da5..0cfdca80 100644 --- a/src/reloaded-code-serdesai/src/tools/todo.rs +++ b/src/reloaded-code-serdesai/src/tools/todo.rs @@ -21,14 +21,14 @@ use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResu // Re-export core types pub use reloaded_code_core::{Todo, TodoPriority, TodoState, TodoStatus}; -/// Tool for reading the current todo list. +/// Tool for reading the current task list. #[derive(Debug, Clone)] pub struct TodoReadTool { definition: ToolDefinition, state: TodoState, } -/// Tool for writing/replacing the todo list. +/// Tool for writing/replacing the task list. #[derive(Debug, Clone)] pub struct TodoWriteTool { definition: ToolDefinition, @@ -103,7 +103,7 @@ impl ToolContext for TodoWriteTool { } } -/// Creates a pair of todo tools with shared state. +/// Creates a linked read/write pair of tools with shared state. /// /// Returns `(TodoReadTool, TodoWriteTool, TodoState)` for cases where /// the caller needs access to the underlying state. From 520247701584fb9aa9b7879e331f24271ed0112e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 17:00:37 +0100 Subject: [PATCH 4/7] docs: resolve review NITs; settle mod placement to rustfmt order - reword redundant 'inlined inline' doc in entry.rs - move doc comment above #[allow(dead_code)] in benches/common/mod.rs - normalize # Arguments bullet style in bash impls and grep.rs to house style ('- `x`: desc') - keep cfg(test) test-module placement as rustfmt requires (rust-llm-tidy reorder would move these 3 mods before their cfg(test) block; rustfmt moves them after - the repo's formatter wins. rust-llm-tidy reports only these 3 non-fatal REORDER records) --- src/reloaded-code-agents/src/lib.rs | 2 +- src/reloaded-code-bubblewrap/src/lib.rs | 2 +- src/reloaded-code-core/benches/common/mod.rs | 2 +- src/reloaded-code-core/src/custom_tool/mod.rs | 2 +- .../src/models/catalog/public/entry.rs | 4 ++-- src/reloaded-code-core/src/tools/bash/blocking_impl.rs | 10 +++++----- src/reloaded-code-core/src/tools/bash/tokio_impl.rs | 10 +++++----- src/reloaded-code-core/src/tools/grep.rs | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/reloaded-code-agents/src/lib.rs b/src/reloaded-code-agents/src/lib.rs index 75af4c3e..a77dc93d 100644 --- a/src/reloaded-code-agents/src/lib.rs +++ b/src/reloaded-code-agents/src/lib.rs @@ -22,6 +22,6 @@ mod loader; mod parser; mod path; mod runtime; -mod types; #[cfg(test)] mod test_helpers; +mod types; diff --git a/src/reloaded-code-bubblewrap/src/lib.rs b/src/reloaded-code-bubblewrap/src/lib.rs index 6467f97e..88120410 100644 --- a/src/reloaded-code-bubblewrap/src/lib.rs +++ b/src/reloaded-code-bubblewrap/src/lib.rs @@ -17,6 +17,6 @@ mod error; mod path_util; mod probe; pub mod profile; -pub mod wrap; #[cfg(test)] mod test_helpers; +pub mod wrap; diff --git a/src/reloaded-code-core/benches/common/mod.rs b/src/reloaded-code-core/benches/common/mod.rs index e25387b6..9f8fe904 100644 --- a/src/reloaded-code-core/benches/common/mod.rs +++ b/src/reloaded-code-core/benches/common/mod.rs @@ -44,12 +44,12 @@ pub enum CorpusSize { Large, } -#[allow(dead_code)] // Used by some benchmarks but not all /// Returns the requested corpus with `\n` line endings replaced by CRLF. /// /// # Arguments /// /// - `size`: the corpus to select. +#[allow(dead_code)] // Used by some benchmarks but not all pub fn corpus_crlf(size: CorpusSize) -> String { corpus_content(size).replace('\n', "\r\n") } diff --git a/src/reloaded-code-core/src/custom_tool/mod.rs b/src/reloaded-code-core/src/custom_tool/mod.rs index e26fda7a..13b5ee71 100644 --- a/src/reloaded-code-core/src/custom_tool/mod.rs +++ b/src/reloaded-code-core/src/custom_tool/mod.rs @@ -88,9 +88,9 @@ pub(crate) mod definition; pub(crate) mod factory; pub(crate) mod registry; pub(crate) mod runtime; -pub(crate) mod tool; #[cfg(test)] pub(crate) mod test_stubs; +pub(crate) mod tool; #[cfg(test)] mod tests { use super::test_stubs::{EchoFactory, TestFactory}; diff --git a/src/reloaded-code-core/src/models/catalog/public/entry.rs b/src/reloaded-code-core/src/models/catalog/public/entry.rs index fe9883af..6fac48e4 100644 --- a/src/reloaded-code-core/src/models/catalog/public/entry.rs +++ b/src/reloaded-code-core/src/models/catalog/public/entry.rs @@ -16,8 +16,8 @@ use crate::models::catalog::internal::Fixed4; use crate::models::ProviderType; use tinyvec::TinyVec; -/// Number of environment variable strings inlined inline (without heap -/// allocation) into a [`ProviderEnvVars`] value. +/// Number of environment variable strings stored inline (without heap +/// allocation) in a [`ProviderEnvVars`] value. pub(crate) const INLINE_PROVIDER_ENV_VARS: usize = 2; /// Model lookup result. diff --git a/src/reloaded-code-core/src/tools/bash/blocking_impl.rs b/src/reloaded-code-core/src/tools/bash/blocking_impl.rs index a9fb975d..d2756bfe 100644 --- a/src/reloaded-code-core/src/tools/bash/blocking_impl.rs +++ b/src/reloaded-code-core/src/tools/bash/blocking_impl.rs @@ -75,11 +75,11 @@ pub fn execute_command( /// Executes a shell command with explicit mode selection. /// /// # Arguments -/// - `mode` - The execution mode (host or Linux sandbox). -/// - `command` - The shell command to execute. -/// - `workdir` - Optional working directory (must be absolute if provided). -/// - `timeout_ms` - Timeout in milliseconds (must be >= 1 and <= max_timeout_ms). -/// - `max_timeout_ms` - Maximum allowed timeout in milliseconds. +/// - `mode`: The execution mode (host or Linux sandbox). +/// - `command`: The shell command to execute. +/// - `workdir`: Optional working directory (must be absolute if provided). +/// - `timeout_ms`: Timeout in milliseconds (must be >= 1 and <= max_timeout_ms). +/// - `max_timeout_ms`: Maximum allowed timeout in milliseconds. /// /// # Errors /// - Returns `ToolError::Validation` if timeout_ms is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs index aafbf1a6..c0e6ca3c 100644 --- a/src/reloaded-code-core/src/tools/bash/tokio_impl.rs +++ b/src/reloaded-code-core/src/tools/bash/tokio_impl.rs @@ -80,11 +80,11 @@ pub async fn execute_command( /// Executes a shell command with explicit mode selection. /// /// # Arguments -/// - `mode` - The execution mode (host or Linux sandbox). -/// - `command` - The shell command to execute. -/// - `workdir` - Optional working directory (must be absolute if provided). -/// - `timeout_ms` - Timeout in milliseconds (must be >= 1 and <= max_timeout_ms). -/// - `max_timeout_ms` - Maximum allowed timeout in milliseconds. +/// - `mode`: The execution mode (host or Linux sandbox). +/// - `command`: The shell command to execute. +/// - `workdir`: Optional working directory (must be absolute if provided). +/// - `timeout_ms`: Timeout in milliseconds (must be >= 1 and <= max_timeout_ms). +/// - `max_timeout_ms`: Maximum allowed timeout in milliseconds. /// /// # Errors /// - Returns `ToolError::Validation` if timeout_ms is 0 or exceeds max_timeout_ms. diff --git a/src/reloaded-code-core/src/tools/grep.rs b/src/reloaded-code-core/src/tools/grep.rs index 92e99fac..7c6f9a8d 100644 --- a/src/reloaded-code-core/src/tools/grep.rs +++ b/src/reloaded-code-core/src/tools/grep.rs @@ -148,7 +148,7 @@ impl GrepOutput { /// /// # Arguments /// - /// * `formatting` - The formatting settings to use for output + /// - `formatting`: The formatting settings to use for output pub fn format(&self, formatting: GrepFormattingSettings) -> String { let line_numbers = formatting.line_numbers(); let max_line_len = formatting.max_line_length(); From b69f33ce8da9f4dc35214896208b858207906f13 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 22:03:39 +0100 Subject: [PATCH 5/7] ci: gate PRs on rust-llm-tidy (apply mode); no config needed Add a Tidy workflow that tidies PR-changed .rs/.md files with the official rust-llm-tidy action (mode: apply) and, on a pull request, commits and pushes the fix commit back to the PR branch. No .rust-llm-tidy.yml config is required: the reorder op now agrees with rustfmt on cfg(test) test-module placement, so the default (no-config) run is clean across the whole tree and CI can gate on it. --- .github/workflows/tidy.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/tidy.yml diff --git a/.github/workflows/tidy.yml b/.github/workflows/tidy.yml new file mode 100644 index 00000000..8068192b --- /dev/null +++ b/.github/workflows/tidy.yml @@ -0,0 +1,36 @@ +name: Tidy + +# Applies rust-llm-tidy fixes to PR-changed .rs/.md files in place and, on a +# pull request, commits + pushes them back to the PR branch (mode: apply). + +on: + pull_request: + branches: [main] + paths: + - "src/**" + - "**/*.md" + - "**/*.MD" + workflow_dispatch: + +jobs: + tidy: + runs-on: ubuntu-latest + permissions: + contents: write # apply: commit + push fixes to the PR branch + pull-requests: write # post a report comment on success/failure + steps: + - uses: actions/checkout@v7 + with: + # Check out the PR branch head, not the auto-generated merge ref: + # apply-mode commits on top of HEAD and pushes back to this branch, + # which would be a non-fast-forward from the merge commit. + ref: ${{ github.head_ref }} + # Full history so the PR base diff is resolvable for changed-files. + fetch-depth: 0 + + - name: Apply tidy fixes to changed files + uses: Sewer56/rust-llm-tidy-action@v1 + with: + rust-project-path: "." + changed-files: "true" + mode: "apply" From 60a51d92bd17bef5a0e7662ce3d38f564ae52350 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 9 Aug 2026 22:27:33 +0100 Subject: [PATCH 6/7] docs: fix intra-doc links so cargo doc -D warnings and rust-llm-tidy gate pass Fix rustdoc unresolved-link warnings in builder.rs, core/context/mod.rs, serdesai/task.rs (field and crate-path doc links) for the cargo doc -D warnings CI gate. rust-llm-tidy's link op collapses inline [x](Self::x) links into reference-style [x] + [x]: Self::x and appends the defs at EOF outside any comment. rustdoc scopes reference defs to a single doc comment, and EOF defs are invalid Rust, so the tidy apply action would re-break builder.rs during the self-review gate. Inline crate:: links (core/serdesai) are tidy-stable; builder.rs field links use variant C: each doc comment using a label carries its own [label]: Self:: def, which tidy leaves alone and rustdoc accepts. --- src/reloaded-code-bubblewrap/src/profile/builder.rs | 4 ++++ src/reloaded-code-core/src/context/mod.rs | 2 +- src/reloaded-code-serdesai/src/agent_runtime/task.rs | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/reloaded-code-bubblewrap/src/profile/builder.rs b/src/reloaded-code-bubblewrap/src/profile/builder.rs index 77ade6fd..ddbf7e0c 100644 --- a/src/reloaded-code-bubblewrap/src/profile/builder.rs +++ b/src/reloaded-code-bubblewrap/src/profile/builder.rs @@ -112,8 +112,12 @@ pub struct Builder { /// [`extra_env`]: Self::extra_env pub(crate) clear_env: bool, /// Env vars always set (applied before [`extra_env`]). + /// + /// [`extra_env`]: Self::extra_env pub(crate) default_env: Arc<[EnvVar]>, /// Additional env vars set on top of [`default_env`]. + /// + /// [`default_env`]: Self::default_env pub(crate) extra_env: Arc<[EnvVar]>, /// Tracks whether `bwrap` is usable (checked during [`build`](Self::build)). pub(crate) availability: Availability, diff --git a/src/reloaded-code-core/src/context/mod.rs b/src/reloaded-code-core/src/context/mod.rs index 2eda1c6b..0ba8c66e 100644 --- a/src/reloaded-code-core/src/context/mod.rs +++ b/src/reloaded-code-core/src/context/mod.rs @@ -52,7 +52,7 @@ pub const GITHUB_CLI: &str = include_str!("github_cli.txt"); /// Git workflow context - commit creation guidance. /// /// Supplemental context for agents using git via the `bash` tool. -/// Include via [`SystemPromptBuilder::add_context`]. +/// Include via [`SystemPromptBuilder::add_context`](crate::SystemPromptBuilder::add_context). pub const GIT_WORKFLOW: &str = include_str!("git_workflow.txt"); /// Trait for tools that provide guidance for system prompts. diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index ca04bc05..c295a8f9 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -96,7 +96,7 @@ where /// - `model_catalog`: Available models for agent resolution. /// - `credentials`: Credential lookup used to authenticate model requests. /// - `workspace_root`: Project directory exposed to tools. - /// - `profile`: Pre-built sandbox profile for [`BashTool`]. + /// - `profile`: Pre-built sandbox profile for [`BashTool`](crate::BashTool). /// - `sandbox_tmpdir`: Optional owning temp directories that keep the /// profile's backing storage alive for the context's lifetime. /// @@ -260,7 +260,7 @@ where /// - `model_catalog`: Available models for agent resolution. /// - `credentials`: Credential lookup used to authenticate model requests. /// - `workspace_root`: Project directory exposed to tools. - /// - `bash_sandbox`: Pre-built sandbox profile for [`BashTool`]. + /// - `bash_sandbox`: Pre-built sandbox profile for [`BashTool`](crate::BashTool). /// - `_sandbox_tmpdir`: Optional owning temp directories that keep the /// profile's backing storage alive. #[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))] From c78433d2542c30770640399edda37387076f7df8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:23:19 +0000 Subject: [PATCH 7/7] Apply rust-llm-tidy fixes Automated by the rust-llm-tidy GitHub Action. --- src/reloaded-code-models-dev/src/catalog/mod.rs | 4 ++-- .../src/agent_runtime/provider_bridge/mod.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reloaded-code-models-dev/src/catalog/mod.rs b/src/reloaded-code-models-dev/src/catalog/mod.rs index 36ba0bd0..bd3904e2 100644 --- a/src/reloaded-code-models-dev/src/catalog/mod.rs +++ b/src/reloaded-code-models-dev/src/catalog/mod.rs @@ -13,6 +13,8 @@ use std::path::Path; mod load_cache; mod load_result; mod sync; +#[cfg(test)] +mod test_utils; /// Entry point for loading models.dev catalogs. /// @@ -150,8 +152,6 @@ impl ModelsDevCatalog { } } -#[cfg(test)] -mod test_utils; #[cfg(test)] mod tests { use super::*; diff --git a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs index ec0bcdfe..d3c5e909 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/provider_bridge/mod.rs @@ -10,6 +10,9 @@ use reloaded_code_core::{ use serdes_ai_models::{BoxedModel, Model as SerdesModel, ModelError}; use std::sync::Arc; +#[cfg(test)] +mod tests; + const AWS_ACCESS_KEY_ID_ENV_VAR: &str = "AWS_ACCESS_KEY_ID"; const AWS_DEFAULT_REGION_ENV_VAR: &str = "AWS_DEFAULT_REGION"; const AWS_REGION_ENV_VAR: &str = "AWS_REGION"; @@ -1010,6 +1013,3 @@ fn normalize_azure_endpoint(endpoint: &str) -> String { trimmed.to_owned() } } - -#[cfg(test)] -mod tests;