Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
60 changes: 45 additions & 15 deletions cmd/wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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. |
Expand Down Expand Up @@ -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
```
14 changes: 10 additions & 4 deletions cmd/wasm/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"},
Expand Down
8 changes: 4 additions & 4 deletions cmd/wasm/browser/smoke_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
//
// node cmd/wasm/browser/smoke_test.js [--build] [--verbose] [--chrome <path>]
//
// 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";

Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion cmd/wasm/examples/handbook-rag.yaml
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
8 changes: 4 additions & 4 deletions cmd/wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
28 changes: 1 addition & 27 deletions cmd/wasm/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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/<name>` model references from models.
func mockProviders(models map[string]provider.Provider) *provider.Registry {
return provider.NewRegistry(map[string]provider.Factory{
Expand Down Expand Up @@ -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
Expand Down
10 changes: 0 additions & 10 deletions cmd/wasm/node/bridge_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
25 changes: 25 additions & 0 deletions cmd/wasm/node/rag_off_test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading
Loading