diff --git a/Taskfile.yml b/Taskfile.yml index 71dff2f1e0..f92da7a1b6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -131,6 +131,7 @@ tasks: desc: Run portable runtime, tool and provider tests under GOOS=js (requires Node) cmds: - GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./cmd/wasm + - GOOS=js GOARCH=wasm go test -tags docker_agent_wasm_rag -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./cmd/wasm - GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" pkg/model/provider/factory_js_openai_vendor_test.go - GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" -run '^TestLimitLargeToolResults|^TestEnvironmentInfo' ./pkg/hooks/builtins - GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./pkg/embeddedchat ./pkg/fuzzy/... ./pkg/tools/builtin/calculator ./pkg/tools/builtin/datetime ./pkg/tools/builtin/deferred ./pkg/tools/builtin/think ./pkg/tools/builtin/random ./pkg/tools/builtin/todo ./pkg/tools/builtin/memory ./pkg/memory/database/inmemory ./pkg/tools/codemode ./pkg/tools/toon ./pkg/rag ./pkg/rag/strategy @@ -139,12 +140,15 @@ tasks: - GOOS=js GOARCH=wasm go test -tags docker_agent_wasm_cloud -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./cmd/wasm ./pkg/model/provider/bedrock ./pkg/model/provider/gemini ./pkg/model/provider/anthropic/vertex ./pkg/model/provider/vertexai test-wasm-bridge: - desc: Build WASM and test its JavaScript API under Node + desc: Build WASM variants and test their JavaScript API under Node cmds: + - GOOS=js GOARCH=wasm go build -tags docker_agent_wasm_rag -o cmd/wasm/web/docker-agent.wasm ./cmd/wasm + - node cmd/wasm/smoke_test.js + - node --test cmd/wasm/node/rag_test.js - GOOS=js GOARCH=wasm go build -o cmd/wasm/web/docker-agent.wasm ./cmd/wasm - node --test cmd/wasm/web/events_test.js - node cmd/wasm/smoke_test.js - - node --test cmd/wasm/node/bridge_test.js + - node --test cmd/wasm/node/bridge_test.js cmd/wasm/node/rag_off_test.js test-wasm-browser: desc: Test real browser fetch, tools and RAG (requires Chrome and openssl) diff --git a/cmd/wasm/README.md b/cmd/wasm/README.md index 10823fb8a6..713088d1ce 100644 --- a/cmd/wasm/README.md +++ b/cmd/wasm/README.md @@ -21,10 +21,35 @@ cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" cmd/wasm/web/wasm_exec.js ``` The output is a large `.wasm` (the runtime, the YAML parser, three LLM -provider clients, RAG and the MCP client). Size depends on the toolchain -and optional providers; compression and `-ldflags="-s -w"` can reduce transfer +provider clients and the MCP client). Size depends on the toolchain +and enabled features; compression and `-ldflags="-s -w"` can reduce transfer size. No size-reduction ratio is guaranteed. +### Optional RAG + +RAG is disabled by default. Include it explicitly with: + +```sh +GOOS=js GOARCH=wasm go build -tags docker_agent_wasm_rag \ + -o cmd/wasm/web/docker-agent.wasm ./cmd/wasm +``` + +Providers, MCP, portable tools, JavaScript expansion, code mode, TOON and +deferred tools are unchanged. The independent `docker_agent_wasm_cloud` tag +can be combined with `docker_agent_wasm_rag` to enable both features. + +Without RAG, configs declaring a `type: rag` toolset are rejected at session +creation rather than silently skipped. The JavaScript API is unchanged: +`documents` is still accepted and validated, but supplying it cannot enable +RAG in a binary built without it. + +This removes `pkg/rag`'s implementation and its toolset from the production +dependency closure, not the shared `rag/types` and `rag/prompts` packages. +The Go runtime, YAML/config handling, LLM clients, MCP, JavaScript engine, +HTTP tools and embedded model catalog remain. Expect modest savings, +not a minimal binary; the size difference depends on the toolchain and build +flags. + ### Optional cloud providers The default binary keeps the original OpenAI / Anthropic / Gemini API set; @@ -55,14 +80,17 @@ See `examples/cloud-bedrock.yaml` and `examples/cloud-vertex.yaml`. ## Run (Node) ```sh -# Go tests of the runtime bridge, with a mocked provider registry. +# Go runtime bridge tests, including default, RAG-enabled and cloud builds. task test-wasm-js # Smoke test of parseConfig/listAgents against the built binary. node cmd/wasm/smoke_test.js -# JS bridge tests: session handles, chat(), failure modes. +# JS bridge tests against the default build: session handles, chat(), failure modes. node --test cmd/wasm/node/bridge_test.js + +# Build and test both default and RAG-enabled JavaScript APIs. +task test-wasm-bridge ``` Both Node scripts load `web/docker-agent.wasm` through `node/boot.js`, which @@ -76,9 +104,10 @@ task test-wasm-browser CHROME_BIN=/path/to/chrome task test-wasm-browser ``` -Requires Chrome and `openssl`; no npm dependencies. The runner starts local -mock model and HTTPS proxy servers, tests real browser streaming, tools, -RAG, approval, cancellation and proxy redirects, then removes its temporary +Requires Chrome and `openssl`; no npm dependencies. The task builds with +`docker_agent_wasm_rag` so RAG is covered. The runner starts local mock model +and HTTPS proxy servers, tests real browser streaming, tools, RAG, approval, +cancellation and proxy redirects, then removes its temporary profile and certificate. It does not contact model services. This validates Chromium; Safari and Firefox are not covered. @@ -212,7 +241,7 @@ when a model's API key is missing from `env`, or when `agentName` is unknown. | `toolProxy?` | HTTPS URL of a trusted egress proxy (`httpclient.WithEgressProxy`). Browsers cannot enforce the SSRF guard on `fetch`, so remote MCP, `fetch`, `api` and `openapi` requests are routed through it and fail closed without one (unless the toolset sets `allow_private_ips: true`). | | `oauthRedirectURI?` | `redirect_uri` advertised for MCP server OAuth flows; the flow itself is relayed to the host as an `elicitation` event. | | `autoApprove?` | Run tool calls without asking, like `--yolo`. Otherwise calls the safety policy does not clear raise a `tool_confirmation` event. | -| `documents?` | `{"handbook/leave.md": "...", ...}` — the session's documents, keyed by logical path. They are the only thing `type: rag` toolsets can index: a RAG's `docs` select among these paths (exact path, directory prefix or glob), and a `docs` entry selecting nothing rejects the session. Values must be strings; at most 1000 documents and 16 MiB in total. See `examples/handbook-rag.yaml`. | +| `documents?` | `{"handbook/leave.md": "...", ...}` — the session's documents, keyed by logical path. When RAG is enabled with `docker_agent_wasm_rag`, they are the only thing `type: rag` toolsets can index: a RAG's `docs` select among these paths (exact path, directory prefix or glob), and a `docs` entry selecting nothing rejects the session. Values must be strings; at most 1000 documents and 16 MiB in total, including in no-RAG builds. See `examples/handbook-rag.yaml`. | The trusted proxy receives the target URL in the `url` query parameter, with the original method, body and credentials. It must enforce destination IP @@ -309,7 +338,7 @@ What the browser build supports, and what it refuses and why. | Area | In the browser | | --- | --- | -| Toolsets | `datetime`, `mcp` (remote only), [`calculator`](../../docs/tools/calculator/index.md), `think`, `random`, `todo`, `plan`, `memory`, `user_prompt`, `session_context`, `fetch`, `api`, `openapi`, `model_picker`, `rag` (over `documents`). See `examples/portable-team.yaml` and `examples/handbook-rag.yaml`. | +| Toolsets | `datetime`, `mcp` (remote only), [`calculator`](../../docs/tools/calculator/index.md), `think`, `random`, `todo`, `plan`, `memory`, `user_prompt`, `session_context`, `fetch`, `api`, `openapi`, `model_picker`, `rag` (over `documents`, opt-in with `docker_agent_wasm_rag`). See `examples/portable-team.yaml` and `examples/handbook-rag.yaml`. | | Toolset options | `tools`, `readonly`, `instruction`, `model`, `toon`, `defer`, `timeout`, `allow_private_ips`. | | Agent features | `code_mode_tools`, sub-agents, handoffs, fallbacks, compaction, `add_date`, `add_environment_info`, structured output, `${...}` JavaScript in instructions and descriptions. | | Hooks | `type: builtin` only: `add_context`, `add_date`, `add_environment_info`, `limit_large_tool_results`, `max_iterations`, `redact_secrets`. `limit_large_tool_results` keeps only the bounded tail excerpt: there is no filesystem to spill the full result to, and the notice says so. | @@ -334,7 +363,7 @@ evaluator is not wired. MCP: in a browser they go through `toolProxy` and fail closed without one, unless the toolset sets `allow_private_ips: true`. -`rag` runs the shared `pkg/rag` pipeline — `bm25`, `chunked-embeddings` and +When enabled, `rag` runs the shared `pkg/rag` pipeline — `bm25`, `chunked-embeddings` and `semantic-embeddings`, fusion and reranking — over the session's `documents` instead of files: the index is built in memory when the tool first starts, results report the documents' logical paths, `return_full_content` reads @@ -352,7 +381,8 @@ documents is rejected when the session is created. `tasks`, `environment`, `scheduler`, `webhook`, `open_url`, `a2a`, `mcp_catalog` and `background_agents` need a process, a filesystem, a socket or the host environment and are not registered. -- **RAG**: a strategy `database` (nothing persists), `chunking.code_aware` +- **RAG**: all `type: rag` toolsets in a no-RAG build (see *Optional RAG*). + When enabled, a strategy `database` (nothing persists), `chunking.code_aware` (tree-sitter needs cgo) and `respect_vcs: true` (no checkout) are rejected; `docs` never reach the filesystem. - **Hooks**: `type: command` and the builtins that read files or run git. @@ -379,7 +409,8 @@ documents is rejected when the session is created. | --- | --- | | `main.go` | JS API registration, `parseConfig`/`listAgents`, `createSession`, the stateless `chat()`/`abort()`. | | `runtime.go` | Builds an `embeddedchat.Session` from YAML with the browser registries and loader features; audits the config for host-only features. | -| `toolsets.go` | The per-session toolset registry: remote MCP plus the portable builtins, `rag` over the session documents, and the per-toolset browser checks. | +| `toolsets.go` | The per-session toolset registry: remote MCP plus the portable builtins, build-selected RAG registration, and the per-toolset browser checks. | +| `rag.go` / `rag_off.go` | Build-tagged RAG implementation and the no-RAG registry variant. | | `session.go` | `chatSession`: one conversation, its lifetime context, send/confirm/abort/restart/close. JS-agnostic, tested from Go. | | `events.go` | Projects runtime events onto the JS event shapes. | | `handle.go` | The JS session object and the lifetime of its callbacks. | @@ -430,7 +461,6 @@ GOOS=js GOARCH=wasm go build -o cmd/wasm/web/docker-agent.wasm ./cmd/wasm # Runtime bridge tests under Node's Go/WASM runner, with mocked models. task test-wasm-js -# JS surface against the built binary. -node cmd/wasm/smoke_test.js -node --test cmd/wasm/node/bridge_test.js +# JS surface against both default and RAG-enabled binaries. +task test-wasm-bridge ``` diff --git a/cmd/wasm/bridge_test.go b/cmd/wasm/bridge_test.go index 8c66dae8e2..09374e2e84 100644 --- a/cmd/wasm/bridge_test.go +++ b/cmd/wasm/bridge_test.go @@ -13,7 +13,6 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/model/provider" - "github.com/docker/docker-agent/pkg/rag" ) func TestCompatChatDeclinesConfirmationWithoutHandler(t *testing.T) { @@ -161,13 +160,20 @@ func TestParseDecisionAndElicitationAnswer(t *testing.T) { func TestJSDocuments(t *testing.T) { eval := func(src string) js.Value { return js.Global().Call("eval", "("+src+")") } - docs, err := jsDocuments(eval("undefined")) + for _, src := range []string{"undefined", "null"} { + docs, err := jsDocuments(eval(src)) + require.NoError(t, err) + assert.Nil(t, docs, "no documents means the rag toolset has nothing to select from") + } + + docs, err := jsDocuments(eval("({})")) require.NoError(t, err) - assert.Nil(t, docs, "no documents means the rag toolset has nothing to select from") + assert.NotNil(t, docs) + assert.Empty(t, docs) docs, err = jsDocuments(eval("({'guide.md': 'hello', 'notes/todo.md': ''})")) require.NoError(t, err) - assert.Equal(t, rag.Documents{"guide.md": []byte("hello"), "notes/todo.md": []byte{}}, docs) + assert.Equal(t, map[string][]byte{"guide.md": []byte("hello"), "notes/todo.md": {}}, docs) for name, tc := range map[string]struct{ src, want string }{ "not an object": {"'guide.md'", "must be an object"}, diff --git a/cmd/wasm/browser/smoke_test.js b/cmd/wasm/browser/smoke_test.js index 86df24cd00..f4716c0189 100644 --- a/cmd/wasm/browser/smoke_test.js +++ b/cmd/wasm/browser/smoke_test.js @@ -6,9 +6,9 @@ // // node cmd/wasm/browser/smoke_test.js [--build] [--verbose] [--chrome ] // -// Builds web/docker-agent.wasm when missing (or with --build). The -// in-browser scenarios live in page.js. Requires Chrome, openssl and the Go -// toolchain that built the binary (for wasm_exec.js). +// Builds web/docker-agent.wasm with RAG when missing (or with --build). +// An existing binary must include docker_agent_wasm_rag. Scenarios live in +// page.js. Requires Chrome, openssl and the matching Go toolchain. "use strict"; @@ -35,7 +35,7 @@ function goroot() { function ensureWasm() { if (fs.existsSync(wasmPath) && !args.includes("--build")) return; console.log("building", path.relative(process.cwd(), wasmPath)); - execFileSync("go", ["build", "-o", wasmPath, "./cmd/wasm"], { + execFileSync("go", ["build", "-tags", "docker_agent_wasm_rag", "-o", wasmPath, "./cmd/wasm"], { cwd: path.join(root, "..", ".."), env: { ...process.env, GOOS: "js", GOARCH: "wasm" }, stdio: "inherit", diff --git a/cmd/wasm/examples/handbook-rag.yaml b/cmd/wasm/examples/handbook-rag.yaml index 9c90569cdc..e1da487efc 100644 --- a/cmd/wasm/examples/handbook-rag.yaml +++ b/cmd/wasm/examples/handbook-rag.yaml @@ -1,5 +1,6 @@ # A keyword-search (BM25) assistant over documents the host hands to the -# session. In the browser build (cmd/wasm) there is no filesystem: `docs` +# session. The browser build requires -tags docker_agent_wasm_rag. +# In the browser (cmd/wasm) there is no filesystem: `docs` # select among the paths of createSession's `documents` option instead, e.g. # # await dockerAgent.createSession({ diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 615c7ac0a8..a8682f9220 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -17,7 +17,8 @@ // // GOOS=js GOARCH=wasm go build -o web/docker-agent.wasm ./cmd/wasm // -// and loaded by web/index.html via wasm_exec.js. +// and loaded by web/index.html via wasm_exec.js. RAG is opt-in with +// -tags docker_agent_wasm_rag. // // What this entry point does NOT do, by construction: run processes, read // or write files, listen on sockets, persist sessions. Configs that need @@ -87,7 +88,6 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config" - "github.com/docker/docker-agent/pkg/rag" ) // compatState tracks the chat() call in flight so abort() can cancel it. @@ -198,7 +198,7 @@ const ( // jsDocuments copies a {path: content} object into the session's documents. // Values must be strings: there is no fallback that could quietly index a // stringified object. -func jsDocuments(v js.Value) (rag.Documents, error) { +func jsDocuments(v js.Value) (map[string][]byte, error) { switch v.Type() { case js.TypeUndefined, js.TypeNull: return nil, nil @@ -210,7 +210,7 @@ func jsDocuments(v js.Value) (rag.Documents, error) { if keys.Length() > maxDocuments { return nil, fmt.Errorf("%d documents exceed the limit of %d", keys.Length(), maxDocuments) } - documents := make(rag.Documents, keys.Length()) + documents := make(map[string][]byte, keys.Length()) total := 0 for i := range keys.Length() { path := keys.Index(i).String() diff --git a/cmd/wasm/mock_test.go b/cmd/wasm/mock_test.go index 88402a274b..8595638744 100644 --- a/cmd/wasm/mock_test.go +++ b/cmd/wasm/mock_test.go @@ -4,12 +4,9 @@ package main import ( "context" - "errors" "fmt" "io" - "strings" "sync" - "sync/atomic" "testing" "github.com/stretchr/testify/require" @@ -22,7 +19,6 @@ import ( "github.com/docker/docker-agent/pkg/model/provider/base" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelsdev" - "github.com/docker/docker-agent/pkg/rag" "github.com/docker/docker-agent/pkg/teamloader" "github.com/docker/docker-agent/pkg/tools" ) @@ -145,28 +141,6 @@ func stop(finish chat.FinishReason, input, output int64) chat.MessageStreamRespo } } -// keywordEmbedder is an offline embedding model: vectors count the words -// "vacation" and "expense" so related texts land close together. -type keywordEmbedder struct { - calls atomic.Int64 -} - -func (e *keywordEmbedder) ID() modelsdev.ID { return modelsdev.NewID("mock", "embed") } -func (e *keywordEmbedder) BaseConfig() base.Config { return base.Config{} } - -func (e *keywordEmbedder) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) { - return nil, errors.New("embedding model only") -} - -func (e *keywordEmbedder) CreateEmbedding(_ context.Context, text string) (*base.EmbeddingResult, error) { //nolint:unparam // provider.EmbeddingProvider signature - e.calls.Add(1) - lower := strings.ToLower(text) - return &base.EmbeddingResult{ - Embedding: []float64{float64(strings.Count(lower, "vacation")), float64(strings.Count(lower, "expense")), 0.1}, - TotalTokens: 1, - }, nil -} - // mockProviders serves `mock/` model references from models. func mockProviders(models map[string]provider.Provider) *provider.Registry { return provider.NewRegistry(map[string]provider.Factory{ @@ -220,7 +194,7 @@ func (e *echoToolSet) callCount() int { func testHost(echo *echoToolSet, models map[string]provider.Provider) host { return host{ providers: mockProviders(models), - newToolsets: func(documents rag.Documents) teamloader.ToolsetRegistry { + newToolsets: func(documents map[string][]byte) teamloader.ToolsetRegistry { creators := browserToolsetCreators(documents) creators["echo"] = func(context.Context, latest.Toolset, string, *config.RuntimeConfig, string) (tools.ToolSet, error) { return echo, nil diff --git a/cmd/wasm/node/bridge_test.js b/cmd/wasm/node/bridge_test.js index 34087078de..8eaff5a3bb 100644 --- a/cmd/wasm/node/bridge_test.js +++ b/cmd/wasm/node/bridge_test.js @@ -109,16 +109,6 @@ agents: await session.close(); }); - it("indexes rag toolsets over the documents option, never over files", async () => { - const yaml = require("node:fs").readFileSync(require("node:path").join(__dirname, "..", "examples", "handbook-rag.yaml"), "utf8"); - const env = { OPENROUTER_API_KEY: "k" }; - await assert.rejects(dockerAgent.createSession({ yaml, env }), /selects none of the supplied documents/); - await assert.rejects(dockerAgent.createSession({ yaml, env, documents: "handbook" }), /options.documents: must be an object/); - await assert.rejects(dockerAgent.createSession({ yaml, env, documents: { "handbook/leave.md": 42 } }), /must be a string/); - const session = await dockerAgent.createSession({ yaml, env, documents: { "handbook/leave.md": "25 days of vacation" } }); - await session.close(); - }); - it("rejects an unknown agent name and a non-https tool proxy", async () => { const env = { LOCAL_KEY: "k" }; await assert.rejects(dockerAgent.createSession({ yaml: unreachableAgent, env, agentName: "nope" }), /agent "nope" not found/); diff --git a/cmd/wasm/node/rag_off_test.js b/cmd/wasm/node/rag_off_test.js new file mode 100644 index 0000000000..94fc71bb00 --- /dev/null +++ b/cmd/wasm/node/rag_off_test.js @@ -0,0 +1,25 @@ +// Run against the default build (without docker_agent_wasm_rag). +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { before, it } = require("node:test"); +const { bootDockerAgent } = require("./boot.js"); + +const yaml = fs.readFileSync(path.join(__dirname, "..", "examples", "handbook-rag.yaml"), "utf8"); +let dockerAgent; +before(async () => { + dockerAgent = await bootDockerAgent(); +}); + +it("rejects RAG configs before checking provider credentials", async () => { + for (const documents of [undefined, {}, { "handbook/leave.md": "25 days of vacation" }]) { + await assert.rejects(dockerAgent.createSession({ yaml, documents }), /toolset type "rag"/); + } +}); + +it("keeps the documents input validation without RAG", async () => { + await assert.rejects(dockerAgent.createSession({ yaml, documents: "handbook" }), /options.documents: must be an object/); + await assert.rejects(dockerAgent.createSession({ yaml, documents: { "handbook/leave.md": 42 } }), /must be a string/); +}); diff --git a/cmd/wasm/node/rag_test.js b/cmd/wasm/node/rag_test.js new file mode 100644 index 0000000000..c21cce9895 --- /dev/null +++ b/cmd/wasm/node/rag_test.js @@ -0,0 +1,23 @@ +// Run against a build with -tags docker_agent_wasm_rag. +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { before, it } = require("node:test"); +const { bootDockerAgent } = require("./boot.js"); + +const yaml = fs.readFileSync(path.join(__dirname, "..", "examples", "handbook-rag.yaml"), "utf8"); +let dockerAgent; +before(async () => { + dockerAgent = await bootDockerAgent(); +}); + +it("indexes rag toolsets over the documents option, never over files", async () => { + const env = { OPENROUTER_API_KEY: "k" }; + await assert.rejects(dockerAgent.createSession({ yaml, env }), /selects none of the supplied documents/); + await assert.rejects(dockerAgent.createSession({ yaml, env, documents: "handbook" }), /options.documents: must be an object/); + await assert.rejects(dockerAgent.createSession({ yaml, env, documents: { "handbook/leave.md": 42 } }), /must be a string/); + const session = await dockerAgent.createSession({ yaml, env, documents: { "handbook/leave.md": "25 days of vacation" } }); + await session.close(); +}); diff --git a/cmd/wasm/rag.go b/cmd/wasm/rag.go new file mode 100644 index 0000000000..1e172244ab --- /dev/null +++ b/cmd/wasm/rag.go @@ -0,0 +1,83 @@ +//go:build js && wasm && docker_agent_wasm_rag + +package main + +import ( + "cmp" + "context" + "errors" + "fmt" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/rag" + "github.com/docker/docker-agent/pkg/teamloader" + "github.com/docker/docker-agent/pkg/tools" + ragtool "github.com/docker/docker-agent/pkg/tools/builtin/rag" +) + +func registerRAG(creators map[string]teamloader.ToolsetCreator, documents map[string][]byte) { + creators["rag"] = ragCreator(documents) +} + +// ragCreator is ragtool.CreateToolSet over the session's documents: the +// manager indexes them in memory instead of files and starts no watcher, and +// the toolset keeps its tool, events and start behaviour. +func ragCreator(input map[string][]byte) teamloader.ToolsetCreator { + documents := rag.Documents(input) + if documents == nil { + documents = rag.Documents{} // nil would mean "read files" + } + return func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + if err := checkRAG(toolset, documents); err != nil { + return nil, err + } + ragName := cmp.Or(toolset.Name, "rag") + mgr, err := rag.NewManager(ctx, ragName, toolset.RAGConfig, rag.ManagersBuildConfig{ + ParentDir: parentDir, + ModelsGateway: runConfig.ModelsGateway, + Env: runConfig.EnvProvider(), + Models: runConfig.Models, + Providers: runConfig.Providers, + RuntimeConfig: runConfig, + Documents: documents, + }) + if err != nil { + return nil, fmt.Errorf("failed to create RAG manager: %w", err) + } + return ragtool.New(mgr, cmp.Or(mgr.ToolName(), ragName), ragtool.WithIndexingTimeout(toolset.RAGConfig.GetIndexingTimeout())), nil + } +} + +// checkRAG rejects what the session documents cannot honour: docs that +// select none of them, a database (nothing persists), code-aware chunking +// (tree-sitter needs cgo) and VCS ignore files (there is no checkout). +func checkRAG(ts latest.Toolset, documents map[string][]byte) error { + if ts.RAGConfig == nil { + return errors.New("rag toolset requires a rag_config block") + } + var errs []error + if cfg := ts.RAGConfig; cfg.RespectVCS != nil && *cfg.RespectVCS { + errs = append(errs, errors.New("respect_vcs: there is no VCS checkout in the browser")) + } + checkDocs := func(loc string, docs []string) { + if _, err := rag.SelectDocuments(browserWorkingDir, rag.GetAbsolutePaths(browserWorkingDir, docs), documents); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", loc, err)) + } + } + checkDocs("docs", ts.RAGConfig.Docs) + for i, s := range ts.RAGConfig.Strategies { + loc := fmt.Sprintf("strategies[%d]", i) + checkDocs(loc+".docs", s.Docs) + if !s.Database.IsEmpty() { + errs = append(errs, fmt.Errorf("%s.database: nothing is persisted in the browser; documents are indexed in memory for the session", loc)) + } + if s.Chunking.CodeAware { + errs = append(errs, fmt.Errorf("%s.chunking.code_aware: tree-sitter needs cgo and is not available in the browser", loc)) + } + if respectVCS, _ := s.Params["respect_vcs"].(bool); respectVCS { + errs = append(errs, fmt.Errorf("%s.respect_vcs: there is no VCS checkout in the browser", loc)) + } + } + return errors.Join(errs...) +} diff --git a/cmd/wasm/rag_off.go b/cmd/wasm/rag_off.go new file mode 100644 index 0000000000..0418dc6ce2 --- /dev/null +++ b/cmd/wasm/rag_off.go @@ -0,0 +1,15 @@ +//go:build js && wasm && !docker_agent_wasm_rag + +package main + +import ( + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/teamloader" +) + +func registerRAG(map[string]teamloader.ToolsetCreator, map[string][]byte) {} + +func checkRAG(latest.Toolset, map[string][]byte) error { + // Strict loading rejects the unregistered toolset before model construction. + return nil +} diff --git a/cmd/wasm/rag_off_test.go b/cmd/wasm/rag_off_test.go new file mode 100644 index 0000000000..2bbeb71dcd --- /dev/null +++ b/cmd/wasm/rag_off_test.go @@ -0,0 +1,54 @@ +//go:build js && wasm && !docker_agent_wasm_rag + +package main + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/options" +) + +func TestBrowserRAGDisabled(t *testing.T) { + registry := browserToolsets(nil) + assert.False(t, registry.Has("rag")) + _, err := registry.CreateTool(t.Context(), latest.Toolset{Type: "rag"}, "/", testRunConfig(), "") + require.ErrorContains(t, err, "unknown toolset type: rag") +} + +func TestRAGDisabledRejectsConfigBeforeModelConstruction(t *testing.T) { + yaml, err := os.ReadFile("examples/handbook-rag.yaml") + require.NoError(t, err) + calls := 0 + h := host{ + providers: provider.NewRegistry(map[string]provider.Factory{ + "openai": func(context.Context, *latest.ModelConfig, environment.Provider, ...options.Opt) (provider.Provider, error) { + calls++ + return newScriptedModel("openai/test"), nil + }, + }), + newToolsets: browserToolsets, + } + for _, tc := range []struct { + name string + documents map[string][]byte + }{ + {name: "omitted"}, + {name: "empty", documents: map[string][]byte{}}, + {name: "supplied", documents: map[string][]byte{"handbook/leave.md": []byte("25 days of vacation")}}, + } { + t.Run(tc.name, func(t *testing.T) { + s, err := h.openSession(t.Context(), sessionOptions{YAML: string(yaml), Documents: tc.documents}) + require.ErrorContains(t, err, `toolset type "rag"`) + assert.Nil(t, s) + assert.Zero(t, calls) + }) + } +} diff --git a/cmd/wasm/rag_test.go b/cmd/wasm/rag_test.go new file mode 100644 index 0000000000..d9a89c8f70 --- /dev/null +++ b/cmd/wasm/rag_test.go @@ -0,0 +1,170 @@ +//go:build js && wasm && docker_agent_wasm_rag + +package main + +import ( + "context" + "errors" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/modelsdev" + "github.com/docker/docker-agent/pkg/tools" +) + +func TestBrowserRAGRegistration(t *testing.T) { + registry := browserToolsets(nil) + require.True(t, registry.Has("rag")) + for name, tc := range map[string]struct { + toolset latest.Toolset + want string + }{ + "without config": {latest.Toolset{Type: "rag"}, "requires a rag_config"}, + "docs not supplied": {latest.Toolset{Type: "rag", RAGConfig: &latest.RAGConfig{Docs: []string{"guide.md"}, Strategies: []latest.RAGStrategyConfig{{Type: "bm25"}}}}, `"/guide.md" selects none of the supplied documents []`}, + } { + t.Run(name, func(t *testing.T) { + _, err := registry.CreateTool(t.Context(), tc.toolset, "/", testRunConfig(), "") + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestRAGRejectsWhatDocumentsCannotHonour(t *testing.T) { + for name, tc := range map[string]struct { + strategy string + want string + }{ + "database": {"database: rag.db", "strategies[0].database: nothing is persisted"}, + "code_aware": {"chunking:\n code_aware: true", "strategies[0].chunking.code_aware: tree-sitter needs cgo"}, + "respect_vcs": {"respect_vcs: true", "strategies[0].respect_vcs: there is no VCS"}, + } { + t.Run(name, func(t *testing.T) { + yaml := ` +agents: + root: + model: mock/root + toolsets: + - type: rag + ref: guide +rag: + guide: + docs: [guide.md] + strategies: + - type: bm25 + ` + strings.ReplaceAll(tc.strategy, "\n", "\n ") + ` +` + h := testHost(&echoToolSet{}, map[string]provider.Provider{"root": newScriptedModel("mock/root")}) + _, err := h.openSession(t.Context(), sessionOptions{YAML: yaml, Documents: map[string][]byte{"guide.md": []byte("hi")}}) + require.ErrorContains(t, err, tc.want) + }) + } +} + +const ragAgentYAML = ` +models: + embed: + provider: mock + model: embed +agents: + root: + model: mock/root + instruction: Answer from the handbook. + toolsets: + - type: rag + ref: handbook +rag: + handbook: + docs: [handbook] + strategies: + - type: bm25 + - type: chunked-embeddings + embedding_model: embed + vector_dimensions: 3 + max_indexing_concurrency: 1 + threshold: 0 + results: + limit: 2 +` + +var handbook = map[string][]byte{ + "handbook/leave.md": []byte("Vacation: everyone gets 25 days of vacation per year. Vacation requests go to your manager."), + "handbook/expenses.md": []byte("Expenses: file expense reports within 30 days with receipts attached."), + "private/salaries.md": []byte("Salaries are confidential."), +} + +func TestRAGIndexesTheSessionDocuments(t *testing.T) { + models := map[string]provider.Provider{ + "root": newScriptedModel("mock/root", toolTurn("handbook", `{"query":"vacation days"}`), textTurn("25 days")), + "embed": &keywordEmbedder{}, + } + h := testHost(&echoToolSet{}, models) + s := openTestSession(t, h, sessionOptions{YAML: ragAgentYAML, Documents: handbook}) + + var c collectingEmitter + _, err := s.send("how much vacation do I get?", c.emit) + require.NoError(t, err) + assert.Empty(t, c.find("tool_confirmation"), "the rag tool is read-only") + results := c.find("tool_result") + require.Len(t, results, 1) + output := results[0]["output"].(string) + assert.Contains(t, output, `"source_path":"handbook/leave.md"`, "results carry the host's logical paths") + assert.Contains(t, output, "25 days of vacation") + assert.NotContains(t, output, "salaries", "docs: [handbook] leaves the other documents out") + assert.Positive(t, models["embed"].(*keywordEmbedder).calls.Load(), "the embedding strategy went through the mock model") + + models["root"] = newScriptedModel("mock/root", toolTurn("handbook", `{"query":"vacation"}`), textTurn("unlimited")) + other := openTestSession(t, h, sessionOptions{YAML: strings.ReplaceAll(ragAgentYAML, "docs: [handbook]", "docs: [notes.md]"), Documents: map[string][]byte{"notes.md": []byte("vacation is unlimited")}}) + var c2 collectingEmitter + _, err = other.send("vacation?", c2.emit) + require.NoError(t, err) + require.Len(t, c2.find("tool_result"), 1) + assert.Contains(t, c2.find("tool_result")[0]["output"], "unlimited") + assert.NotContains(t, c2.find("tool_result")[0]["output"], "25 days", "documents are scoped to their session") +} + +func TestRAGExampleLoadsOverTheSessionDocuments(t *testing.T) { + yaml, err := os.ReadFile("examples/handbook-rag.yaml") + require.NoError(t, err) + opts := sessionOptions{YAML: string(yaml), Env: map[string]string{"OPENROUTER_API_KEY": "k"}} + for _, documents := range []map[string][]byte{nil, {}} { + opts.Documents = documents + _, err = browserHost.openSession(t.Context(), opts) + require.ErrorContains(t, err, `"/handbook" selects none of the supplied documents []`, "without documents there is nothing to index") + } + + opts.Documents = handbook + s, err := browserHost.openSession(t.Context(), opts) + require.NoError(t, err) + require.NoError(t, s.close()) +} + +// keywordEmbedder is an offline embedding model: vectors count the words +// "vacation" and "expense" so related texts land close together. +type keywordEmbedder struct { + calls atomic.Int64 +} + +func (e *keywordEmbedder) ID() modelsdev.ID { return modelsdev.NewID("mock", "embed") } +func (e *keywordEmbedder) BaseConfig() base.Config { return base.Config{} } + +func (e *keywordEmbedder) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) { + return nil, errors.New("embedding model only") +} + +func (e *keywordEmbedder) CreateEmbedding(_ context.Context, text string) (*base.EmbeddingResult, error) { //nolint:unparam // provider.EmbeddingProvider signature + e.calls.Add(1) + lower := strings.ToLower(text) + return &base.EmbeddingResult{ + Embedding: []float64{float64(strings.Count(lower, "vacation")), float64(strings.Count(lower, "expense")), 0.1}, + TotalTokens: 1, + }, nil +} diff --git a/cmd/wasm/runtime.go b/cmd/wasm/runtime.go index c42bc61dd9..6961bd9700 100644 --- a/cmd/wasm/runtime.go +++ b/cmd/wasm/runtime.go @@ -19,7 +19,6 @@ import ( "github.com/docker/docker-agent/pkg/js" "github.com/docker/docker-agent/pkg/model/provider" "github.com/docker/docker-agent/pkg/modelsdev" - "github.com/docker/docker-agent/pkg/rag" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/teamloader" @@ -48,7 +47,7 @@ type sessionOptions struct { History []chat.Message // Documents are the session's documents, keyed by logical path: the // only thing `type: rag` toolsets can index. Nothing is read from disk. - Documents rag.Documents + Documents map[string][]byte } // host holds the registries every session is built from. The browser entry @@ -58,7 +57,7 @@ type sessionOptions struct { // rag toolset sees the session's documents. type host struct { providers *provider.Registry - newToolsets func(documents rag.Documents) teamloader.ToolsetRegistry + newToolsets func(documents map[string][]byte) teamloader.ToolsetRegistry } var browserHost = host{providers: demoProviders, newToolsets: browserToolsets} @@ -179,7 +178,7 @@ func (h host) newSession(ctx context.Context, opts sessionOptions) (*embeddedcha // toolset declarations that point at them (see checkBrowserToolset). The // loader only warns when a toolset fails to build, so this is what turns // those into a createSession rejection. -func checkBrowserConfig(ctx context.Context, cfg *latest.Config, agentName string, env environment.Provider, documents rag.Documents) error { +func checkBrowserConfig(ctx context.Context, cfg *latest.Config, agentName string, env environment.Provider, documents map[string][]byte) error { if agentName != "" && !slices.ContainsFunc(cfg.Agents, func(a latest.AgentConfig) bool { return a.Name == agentName }) { return fmt.Errorf("agent %q not found", agentName) } diff --git a/cmd/wasm/toolsets.go b/cmd/wasm/toolsets.go index 4f7c672100..71760f8944 100644 --- a/cmd/wasm/toolsets.go +++ b/cmd/wasm/toolsets.go @@ -3,7 +3,6 @@ package main import ( - "cmp" "context" "errors" "fmt" @@ -16,7 +15,6 @@ import ( "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/js" "github.com/docker/docker-agent/pkg/memory/database/inmemory" - "github.com/docker/docker-agent/pkg/rag" "github.com/docker/docker-agent/pkg/teamloader" "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/tools/builtin/api/client" @@ -27,7 +25,6 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/modelpicker" "github.com/docker/docker-agent/pkg/tools/builtin/openapi" "github.com/docker/docker-agent/pkg/tools/builtin/plan" - ragtool "github.com/docker/docker-agent/pkg/tools/builtin/rag" "github.com/docker/docker-agent/pkg/tools/builtin/random" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" "github.com/docker/docker-agent/pkg/tools/builtin/think" @@ -44,11 +41,11 @@ import ( // A registry is built per session because todo (shared: true), plan and // memory keep state: the agents of one team share it, two sessions never do. // rag indexes the session's documents in place of files. -func browserToolsets(documents rag.Documents) teamloader.ToolsetRegistry { +func browserToolsets(documents map[string][]byte) teamloader.ToolsetRegistry { return teamloader.NewToolsetRegistry(browserToolsetCreators(documents)) } -func browserToolsetCreators(documents rag.Documents) map[string]teamloader.ToolsetCreator { +func browserToolsetCreators(documents map[string][]byte) map[string]teamloader.ToolsetCreator { sharedTodos := sync.OnceValue(func() *todo.ToolSet { return todo.New() }) plans := sync.OnceValue(func() tools.ToolSet { // Plans live in memory and there are no files to move them through. @@ -59,7 +56,7 @@ func browserToolsetCreators(documents rag.Documents) map[string]teamloader.Tools }) memories := sync.OnceValue(inmemory.New) - return map[string]teamloader.ToolsetCreator{ + creators := map[string]teamloader.ToolsetCreator{ "datetime": teamloader.Creator(datetime.CreateToolSet), "mcp": mcpCreator, "calculator": teamloader.Creator(calculator.CreateToolSet), @@ -84,8 +81,9 @@ func browserToolsetCreators(documents rag.Documents) map[string]teamloader.Tools "api": client.Creator(js.NewJsExpander), "openapi": openapiCreator, "model_picker": teamloader.CreatorFromToolset(modelpicker.CreateToolSet), - "rag": ragCreator(documents), } + registerRAG(creators, documents) + return creators } // memoryPersistenceClaim opens the memory toolset's instructions and is @@ -115,37 +113,9 @@ func openapiCreator(ctx context.Context, toolset latest.Toolset, parentDir strin return openapi.Creator(ctx, toolset, parentDir, runConfig, configName) } -// ragCreator is ragtool.CreateToolSet over the session's documents: the -// manager indexes them in memory instead of files and starts no watcher, and -// the toolset keeps its tool, events and start behaviour. -func ragCreator(documents rag.Documents) teamloader.ToolsetCreator { - if documents == nil { - documents = rag.Documents{} // nil would mean "read files" - } - return func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { - if err := checkRAG(toolset, documents); err != nil { - return nil, err - } - ragName := cmp.Or(toolset.Name, "rag") - mgr, err := rag.NewManager(ctx, ragName, toolset.RAGConfig, rag.ManagersBuildConfig{ - ParentDir: parentDir, - ModelsGateway: runConfig.ModelsGateway, - Env: runConfig.EnvProvider(), - Models: runConfig.Models, - Providers: runConfig.Providers, - RuntimeConfig: runConfig, - Documents: documents, - }) - if err != nil { - return nil, fmt.Errorf("failed to create RAG manager: %w", err) - } - return ragtool.New(mgr, cmp.Or(mgr.ToolName(), ragName), ragtool.WithIndexingTimeout(toolset.RAGConfig.GetIndexingTimeout())), nil - } -} - // checkBrowserToolset rejects toolset declarations the browser build cannot // honour, so they fail the load instead of being skipped with a warning. -func checkBrowserToolset(ctx context.Context, ts latest.Toolset, env environment.Provider, documents rag.Documents) error { +func checkBrowserToolset(ctx context.Context, ts latest.Toolset, env environment.Provider, documents map[string][]byte) error { switch ts.Type { case "mcp": return checkRemoteMCP(ts) @@ -194,36 +164,3 @@ func checkOpenAPI(ctx context.Context, ts latest.Toolset, env environment.Provid } return nil } - -// checkRAG rejects what the session documents cannot honour: docs that -// select none of them, a database (nothing persists), code-aware chunking -// (tree-sitter needs cgo) and VCS ignore files (there is no checkout). -func checkRAG(ts latest.Toolset, documents rag.Documents) error { - if ts.RAGConfig == nil { - return errors.New("rag toolset requires a rag_config block") - } - var errs []error - if cfg := ts.RAGConfig; cfg.RespectVCS != nil && *cfg.RespectVCS { - errs = append(errs, errors.New("respect_vcs: there is no VCS checkout in the browser")) - } - checkDocs := func(loc string, docs []string) { - if _, err := rag.SelectDocuments(browserWorkingDir, rag.GetAbsolutePaths(browserWorkingDir, docs), documents); err != nil { - errs = append(errs, fmt.Errorf("%s: %w", loc, err)) - } - } - checkDocs("docs", ts.RAGConfig.Docs) - for i, s := range ts.RAGConfig.Strategies { - loc := fmt.Sprintf("strategies[%d]", i) - checkDocs(loc+".docs", s.Docs) - if !s.Database.IsEmpty() { - errs = append(errs, fmt.Errorf("%s.database: nothing is persisted in the browser; documents are indexed in memory for the session", loc)) - } - if s.Chunking.CodeAware { - errs = append(errs, fmt.Errorf("%s.chunking.code_aware: tree-sitter needs cgo and is not available in the browser", loc)) - } - if respectVCS, _ := s.Params["respect_vcs"].(bool); respectVCS { - errs = append(errs, fmt.Errorf("%s.respect_vcs: there is no VCS checkout in the browser", loc)) - } - } - return errors.Join(errs...) -} diff --git a/cmd/wasm/toolsets_test.go b/cmd/wasm/toolsets_test.go index 4cdb715c6f..7c290833bc 100644 --- a/cmd/wasm/toolsets_test.go +++ b/cmd/wasm/toolsets_test.go @@ -22,7 +22,6 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider" - "github.com/docker/docker-agent/pkg/rag" "github.com/docker/docker-agent/pkg/teamloader" "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/tools/builtin/plan" @@ -64,7 +63,7 @@ func callTool(t *testing.T, ts tools.ToolSet, name, args string) string { func TestBrowserToolsetsServePortableBuiltins(t *testing.T) { registry := browserToolsets(nil) - for _, supported := range []string{"datetime", "mcp", "calculator", "think", "random", "todo", "plan", "memory", "user_prompt", "session_context", "fetch", "api", "openapi", "model_picker", "rag"} { + for _, supported := range []string{"datetime", "mcp", "calculator", "think", "random", "todo", "plan", "memory", "user_prompt", "session_context", "fetch", "api", "openapi", "model_picker"} { assert.True(t, registry.Has(supported), supported) } for _, unsupported := range []string{"shell", "script", "filesystem", "file", "git", "tasks", "environment", "background_jobs", "background_agents", "lsp", "mcp_catalog", "a2a", "webhook", "open_url", "scheduler"} { @@ -75,14 +74,12 @@ func TestBrowserToolsetsServePortableBuiltins(t *testing.T) { toolset latest.Toolset want string }{ - "stdio mcp": {latest.Toolset{Type: "mcp", Command: "npx"}, "stdio MCP servers"}, - "memory path": {latest.Toolset{Type: "memory", Path: "memory.db"}, "memory path"}, - "local spec": {latest.Toolset{Type: "openapi", URL: "./openapi.yaml"}, "only http(s)"}, - "no endpoint": {latest.Toolset{Type: "api"}, "requires an endpoint"}, - "no models": {latest.Toolset{Type: "model_picker"}, "at least one model"}, - "unknown type": {latest.Toolset{Type: "shell"}, "unknown toolset type"}, - "rag without config": {latest.Toolset{Type: "rag"}, "requires a rag_config"}, - "rag docs not supplied": {latest.Toolset{Type: "rag", RAGConfig: &latest.RAGConfig{Docs: []string{"guide.md"}, Strategies: []latest.RAGStrategyConfig{{Type: "bm25"}}}}, `"/guide.md" selects none of the supplied documents []`}, + "stdio mcp": {latest.Toolset{Type: "mcp", Command: "npx"}, "stdio MCP servers"}, + "memory path": {latest.Toolset{Type: "memory", Path: "memory.db"}, "memory path"}, + "local spec": {latest.Toolset{Type: "openapi", URL: "./openapi.yaml"}, "only http(s)"}, + "no endpoint": {latest.Toolset{Type: "api"}, "requires an endpoint"}, + "no models": {latest.Toolset{Type: "model_picker"}, "at least one model"}, + "unknown type": {latest.Toolset{Type: "shell"}, "unknown toolset type"}, } { t.Run(name, func(t *testing.T) { _, err := registry.CreateTool(t.Context(), tc.toolset, "/", testRunConfig(), "") @@ -152,99 +149,6 @@ func TestDatetimeRunsInBrowser(t *testing.T) { assert.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$`, output) } -func TestRAGRejectsWhatDocumentsCannotHonour(t *testing.T) { - for name, tc := range map[string]struct { - strategy string - want string - }{ - "database": {"database: rag.db", "strategies[0].database: nothing is persisted"}, - "code_aware": {"chunking:\n code_aware: true", "strategies[0].chunking.code_aware: tree-sitter needs cgo"}, - "respect_vcs": {"respect_vcs: true", "strategies[0].respect_vcs: there is no VCS"}, - } { - t.Run(name, func(t *testing.T) { - yaml := ` -agents: - root: - model: mock/root - toolsets: - - type: rag - ref: guide -rag: - guide: - docs: [guide.md] - strategies: - - type: bm25 - ` + strings.ReplaceAll(tc.strategy, "\n", "\n ") + ` -` - h := testHost(&echoToolSet{}, map[string]provider.Provider{"root": newScriptedModel("mock/root")}) - _, err := h.openSession(t.Context(), sessionOptions{YAML: yaml, Documents: rag.Documents{"guide.md": []byte("hi")}}) - require.ErrorContains(t, err, tc.want) - }) - } -} - -const ragAgentYAML = ` -models: - embed: - provider: mock - model: embed -agents: - root: - model: mock/root - instruction: Answer from the handbook. - toolsets: - - type: rag - ref: handbook -rag: - handbook: - docs: [handbook] - strategies: - - type: bm25 - - type: chunked-embeddings - embedding_model: embed - vector_dimensions: 3 - max_indexing_concurrency: 1 - threshold: 0 - results: - limit: 2 -` - -var handbook = rag.Documents{ - "handbook/leave.md": []byte("Vacation: everyone gets 25 days of vacation per year. Vacation requests go to your manager."), - "handbook/expenses.md": []byte("Expenses: file expense reports within 30 days with receipts attached."), - "private/salaries.md": []byte("Salaries are confidential."), -} - -func TestRAGIndexesTheSessionDocuments(t *testing.T) { - models := map[string]provider.Provider{ - "root": newScriptedModel("mock/root", toolTurn("handbook", `{"query":"vacation days"}`), textTurn("25 days")), - "embed": &keywordEmbedder{}, - } - h := testHost(&echoToolSet{}, models) - s := openTestSession(t, h, sessionOptions{YAML: ragAgentYAML, Documents: handbook}) - - var c collectingEmitter - _, err := s.send("how much vacation do I get?", c.emit) - require.NoError(t, err) - assert.Empty(t, c.find("tool_confirmation"), "the rag tool is read-only") - results := c.find("tool_result") - require.Len(t, results, 1) - output := results[0]["output"].(string) - assert.Contains(t, output, `"source_path":"handbook/leave.md"`, "results carry the host's logical paths") - assert.Contains(t, output, "25 days of vacation") - assert.NotContains(t, output, "salaries", "docs: [handbook] leaves the other documents out") - assert.Positive(t, models["embed"].(*keywordEmbedder).calls.Load(), "the embedding strategy went through the mock model") - - models["root"] = newScriptedModel("mock/root", toolTurn("handbook", `{"query":"vacation"}`), textTurn("unlimited")) - other := openTestSession(t, h, sessionOptions{YAML: strings.ReplaceAll(ragAgentYAML, "docs: [handbook]", "docs: [notes.md]"), Documents: rag.Documents{"notes.md": []byte("vacation is unlimited")}}) - var c2 collectingEmitter - _, err = other.send("vacation?", c2.emit) - require.NoError(t, err) - require.Len(t, c2.find("tool_result"), 1) - assert.Contains(t, c2.find("tool_result")[0]["output"], "unlimited") - assert.NotContains(t, c2.find("tool_result")[0]["output"], "25 days", "documents are scoped to their session") -} - func TestStatefulToolsetsAreSharedWithinASessionOnly(t *testing.T) { session, other := browserToolsets(nil), browserToolsets(nil) @@ -512,19 +416,6 @@ func TestPortableExampleLoadsWithTheDemoProviders(t *testing.T) { require.NoError(t, s.close()) } -func TestRAGExampleLoadsOverTheSessionDocuments(t *testing.T) { - yaml, err := os.ReadFile("examples/handbook-rag.yaml") - require.NoError(t, err) - opts := sessionOptions{YAML: string(yaml), Env: map[string]string{"OPENROUTER_API_KEY": "k"}} - _, err = browserHost.openSession(t.Context(), opts) - require.ErrorContains(t, err, `"/handbook" selects none of the supplied documents []`, "without documents there is nothing to index") - - opts.Documents = handbook - s, err := browserHost.openSession(t.Context(), opts) - require.NoError(t, err) - require.NoError(t, s.close()) -} - // mockAPI serves a JSON endpoint and an OpenAPI spec describing it from the // wasm process itself: Go's js/wasm net is an in-process loopback, so the // tools' own HTTP client reaches it. diff --git a/docs/guides/go-sdk/index.md b/docs/guides/go-sdk/index.md index aa938ae2ff..d444cc705a 100644 --- a/docs/guides/go-sdk/index.md +++ b/docs/guides/go-sdk/index.md @@ -343,7 +343,7 @@ if err != nil { ts := ragtool.New(mgr, mgr.ToolName(), ragtool.WithIndexingTimeout(ragCfg.GetIndexingTimeout())) ``` -`cmd/wasm/toolsets.go` registers exactly this as the browser's `type: rag` creator, fed by `createSession`'s `documents` option. +With `-tags docker_agent_wasm_rag`, `cmd/wasm/rag.go` registers this as the browser's `type: rag` creator, fed by `createSession`'s `documents` option. RAG is excluded from the default WASM build. ## Loading YAML with Hand-Picked Registries (lean embedding) diff --git a/e2e/dependencies_test.go b/e2e/dependencies_test.go index f55a7ccd3c..047390fa59 100644 --- a/e2e/dependencies_test.go +++ b/e2e/dependencies_test.go @@ -216,6 +216,46 @@ func TestWasmDefaultBinaryDependencies(t *testing.T) { } } +func TestWasmRAGDependencies(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + tags string + rag bool + }{ + {name: "default"}, + {name: "with RAG", tags: "docker_agent_wasm_rag", rag: true}, + {name: "cloud", tags: "docker_agent_wasm_cloud"}, + {name: "cloud with RAG", tags: "docker_agent_wasm_cloud,docker_agent_wasm_rag", rag: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cmd := exec.CommandContext(t.Context(), "go", "list", "-deps", "-tags", tc.tags, "./cmd/wasm") + cmd.Dir = ".." + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + + const rag = "github.com/docker/docker-agent/pkg/rag" + const builtinRAG = "github.com/docker/docker-agent/pkg/tools/builtin/rag" + deps := strings.Fields(string(out)) + if tc.rag { + assert.Contains(t, deps, rag) + assert.Contains(t, deps, builtinRAG) + return + } + for _, dep := range deps { + if dep == rag+"/types" || dep == rag+"/prompts" { + continue + } + for _, prefix := range []string{rag, builtinRAG, "github.com/fsnotify/fsnotify"} { + assert.False(t, dep == prefix || strings.HasPrefix(dep, prefix+"/"), "RAG dependency in the no-RAG WASM binary: %s", dep) + } + } + }) + } +} + // listTransitiveDeps returns the full (non-test) dependency closure of the // given packages, as reported by `go list -deps`. func listTransitiveDeps(t *testing.T, pkgs ...string) map[string]bool {