Ship a conformant ACT component from a constrained language (C++, C#, Go,
…) by implementing a sync, no-stream interface and composing it with a
generic prebuilt Rust adapter via wac plug.
ACT's canonical capability interfaces declare their functions as async func
and carry a stream<tool-event>-bearing tool-result. Only Rust's wit-bindgen
backend generates that cleanly today — the cpp / csharp / go backends
either panic on stream<> inside a variant or emit no glue for async func.
This repo unblocks them: it pairs the sync mirror WIT (defined in act-spec) with generic prebuilt Rust shims (this repo's deliverable) that lift a sync inner up to the real async ACT surface.
The canonical act:tools / act:sessions WIT is not modified. This repo
adds sibling sync packages and the shims to bridge them.
wac plug
┌────────────────────────────────────────────────────────────────┐
│ composed.wasm (one conformant ACT component) │
│ │
│ exports act:tools/tool-provider@0.2.0 (ASYNC, canonical) │
│ ▲ │
│ ┌──────────┴────────────┐ ┌─────────────────────────┐ │
│ │ tool-shim/ (Rust) │ import │ your inner (C++/C#/Go) │ │
│ │ exports async ──────┼───────▶│ exports SYNC │ │
│ │ imports SYNC │ sync │ act:tools-sync/ │ │
│ │ pure forwarder │ interf.│ tool-provider-sync │ │
│ └───────────────────────┘ └─────────────────────────┘ │
│ act:tools-sync is INTERNALIZED (gone from the imports) │
└────────────────────────────────────────────────────────────────┘
session-shim/ is the identical shape for stateful components:
act:sessions-sync/session-provider-sync@0.1.0 (sync) → async
act:sessions/session-provider@0.2.0.
act:tools@0.2.0 and act:sessions@0.2.0 extracted their data model into
function-free, stream-free types interfaces
(act:tools/types, act:sessions/types). The sync mirror packages here
use those canonical types directly:
// act:tools-sync@0.1.0
interface tool-provider-sync {
use act:core/types@0.4.0.{cbor, metadata, error};
use act:tools/types@0.2.0.{tool-event, list-tools-response}; // reuse!
list-tools: func(metadata: metadata) -> result<list-tools-response, error>;
call-tool: func(name: string, arguments: cbor, metadata: metadata) -> list<tool-event>;
}Because both the shim's async export and the shim's sync import
reference the same act:tools/types::tool-event / list-tools-response,
wit-bindgen generates one shared Rust type for each. The shim therefore
needs no type conversion — call-tool is literally:
async fn call_tool(name: String, arguments: Cbor, metadata: Metadata) -> ToolResult {
ToolResult::Immediate(inner::call_tool(&name, &arguments, &metadata))
}This is the key advance over the proof-of-concept (see below), which predated
the types split: back then the sync package had to define standalone
copies of the data types and the shim carried a conv_* translation layer,
because use-ing from the streaming tool-provider interface made the
cpp/csharp generators walk the stream<tool-event> and panic. use-ing from
the clean types interface does not trip that panic — verified — so the
duplication and the conversion code are both gone.
-
Author your inner component. Implement, in your language, the SYNC interface
act:tools-sync/tool-provider-sync@0.1.0(oract:sessions-sync/session-provider-sync@0.1.0for stateful components). Vendor the WIT fromwit/deps/here. Your guest exports only the sync interface — noasync, nostream, which is exactly what the constrained-language backends can generate. -
Build your inner to a
wasm32-wasip2component. -
Compose with the prebuilt shim:
wac plug tool_shim.wasm --plug your_inner.wasm -o composed.wasm
wacsatisfies the shim's sync import with your inner's sync export and internalizesact:tools-syncentirely.composed.wasmnow exports the canonical asyncact:tools/tool-provider@0.2.0. -
Pack and ship:
act-build pack composed.wasm act-build validate composed.wasm
composed.wasm runs on any stock ACT host (CLI, MCP, HTTP) — it is
indistinguishable from a natively-async Rust component.
The shims build #![no_std] (alloc-only). A pure forwarder has no use for
std's runtime, and dropping it removes the ambient WASI imports that std's
startup pulls in — imports that aren't even in the shim's WIT contract. The
shim provides its own #[global_allocator] (dlmalloc, the same allocator std
uses on wasm), a #[panic_handler], and cabi_realloc (which wasm32-wasip2
otherwise inherits from wasi-libc via std). wit-bindgen's own crate is already
#![no_std] + alloc, and the generated bindings reference only ::core /
alloc, so no codegen changes are needed — just default-features = false on
the wit-bindgen dep to drop the (otherwise inert) std feature.
Measured on these shims:
| shim | std build | no_std build |
|---|---|---|
tool_shim.wasm |
71.3 KB, 13 WASI imports | 36.3 KB, 0 WASI imports |
session_shim.wasm |
68.6 KB, 13 WASI imports | 33.4 KB, 0 WASI imports |
The std build imported wasi:io/{poll,error,streams} and
wasi:cli/{environment,exit,stdin,stdout,stderr,terminal-*} — none of which a
forwarder touches. The no_std shim imports only its declared interfaces
(act:core/types, act:tools/types + the sync inner). For an auditable
toolchain that's the point: a generic adapter whose import surface is exactly
its WIT contract, nothing ambient to explain away.
Only the shim's contribution is cleaned up this way. The composed component's final import surface is the union of shim + your C++/C#/Go inner, so the inner's own language runtime still contributes whatever it imports.
wit/deps/ vendored, committed WIT (builds offline)
act-core-0.4.0/ canonical act:core
act-tools-0.2.0/ canonical act:tools (with types interface)
act-sessions-0.2.0/ canonical act:sessions (with types interface)
act-tools-sync-0.1.0/ vendored from actcore.dev (defined in act-spec)
act-sessions-sync-0.1.0/ vendored from actcore.dev (defined in act-spec)
crates/tool-shim/ Rust (no_std): exports async tools, imports sync (no conv)
crates/session-shim/ Rust (no_std): exports async sessions, imports sync (no conv)
examples/inner-hello/ Rust inner exporting sync tool-provider-sync (smoke test).
A normal std component, and its OWN workspace (excluded
from the shims') so its std wit-bindgen never unifies
with the no_std shims. Builds composed via `wac plug`.
justfile build-shims / build-inner / compose / pack / wit / test
wkg-registry.toml documents the real `wkg wit fetch` source
The per-crate wit/deps/ entries are symlinks into the shared top-level
wit/deps/, so the canonical packages are vendored once.
just build-shims # cargo → tool_shim.wasm, session_shim.wasm
just build-inner # cargo → inner_hello.wasm (the example constrained-style inner)
just compose # wac plug → composed.wasm
just pack # act-build pack + validate
just wit # assert each shim's exported/imported surface
just wit-composed # assert act:tools-sync is internalized in composed.wasm
just test # act info --tools + act call → "Hello, Ada!" (needs a 0.2.0 host)
just allRuntime smoke test — verified.
just testrunsact info composed.wasm --toolsandact call composed.wasm hello … → "Hello, Ada!"against a realacthost, and passes on act 0.10.0 (theact:tools@0.2.0migration has landed). Verified with both thecargo installedactand a local debug build. Static composition is also green (the shim composes, the sync import is internalized, the component packs and validates). Override the binary withACT=/path/to/act just test.The
no_stdshim composes with a normalstdinner (inner-hello) and the result runs unmodified — the runtime call passes a string across the ABI, exercising the shim's owncabi_realloc+ allocator.Note: only
tool-shimis exercised at runtime here, sinceinner-helloexportsact:tools-sync.session-shimis structurally identical and its build + WIT surface are asserted (just wit), but there is no session inner example to compose-and-run yet.
examples/inner-hello/ is a Rust inner used only as a fast end-to-end smoke
test of the shims. The constrained-language proof — a real C++ inner
generated by wit-bindgen cpp, composed through this same shim pattern, loaded
and run by the stock host — is in
components-experimental/sync-shim-poc.
That PoC demonstrated the pattern against act:tools@0.1.0 (with standalone
type copies + a conv_* layer); this repo is its productization against
act:tools@0.2.0, where the types reuse removes the duplication.
The shims are published as OCI components under the act:shim-* family:
| Shim | OCI reference |
|---|---|
act:shim-tools-sync |
ghcr.io/actcore/act/shim-tools-sync:0.1.0 |
act:shim-sessions-sync |
ghcr.io/actcore/act/shim-sessions-sync:0.1.0 |
wkg oci pull ghcr.io/actcore/act/shim-tools-sync:0.1.0 -o shim-tools-sync.wasm
wac plug shim-tools-sync.wasm --plug your_inner.wasm -o composed.wasm
act-build pack composed.wasm && act-build validate composed.wasm- Immediate-only. The shim returns
tool-result::immediate. Truestreamingcannot flow through a sync inner — the sync interface has no stream. This covers the common bounded-result case (the vast majority of tools), not incremental producers. - Does not fix MoonBit's separate bug. MoonBit's blocker is a different
defect (wit-bindgen-moonbit panics on async-export returning a variant with
heap cleanup); this pattern addresses the cpp/csharp/go
stream<>-in-variant- no-async-export family.
Dual-licensed under Apache-2.0 OR MIT, at your option.