skills/cache-expert/references/dynamicinputs.md
This is a short reference on two closely related dagql cache concepts:
These are both about shaping call identity before the resolver actually runs.
Dagql does not treat cache identity as a single opaque digest that fields can tweak however they want. Instead, it models call identity as a structured ResultCall:
Dynamic inputs and implicit inputs are part of that structured model.
That is what makes cache identity more observable and debuggable than the old approach of just mutating the cache key digest in opaque ways.
The general mechanism is FuncWithDynamicInputs / NodeFuncWithDynamicInputs.
The typed callback looks like:
type DynamicInputFunc[T Typed, A any] func(
context.Context,
ObjectResult[T],
A,
*CallRequest,
) error
This runs before cache lookup and before the field resolver body executes. It can mutate the CallRequest, which is the planning-time wrapper around the semantic ResultCall plus request-only cache policy:
ArgsImplicitInputsConcurrencyKeyTTLDoNotCacheIsPersistableSo dynamic inputs are not limited to "rewrite an arg." They can also adjust request policy such as concurrency grouping, TTL, or do-not-cache behavior.
One important distinction:
TTL, DoNotCache, ConcurrencyKey, and IsPersistable affect cache behavior without becoming part of persisted semantic provenanceAn implicit input is the simpler, more specialized mechanism.
It is declared with Field.WithInput(...) and has the form:
type ImplicitInput struct {
Name string
Resolver ImplicitInputResolver
}
The resolver computes an engine-side input value from:
That input is then attached to the call identity as a hidden input.
Important properties:
ResultCall.ImplicitInputs, not flattened away into an opaque digest mutationThis is the part that makes the model much easier to reason about. The client/session/schema scoping information is still visible in the structured call data.
The call planning order in dagql/objects.go is important:
inputArgs.frameArgs for the ResultCall.inputArgs.CallRequest.req.Args.That recomputation step is a very important detail.
It means dynamic input hooks can rewrite explicit args, and any implicit inputs that depend on those args will then be recalculated from the final rewritten values.
There is a dedicated test for this behavior: TestImplicitInputRecomputedAfterCacheConfigIDRewrite.
The most common use of implicit inputs is scoping.
For example:
The important point is that the scope becomes part of the recipe as a real, named input. So the recipe digest changes, but it changes through structured data rather than through an invisible custom hash tweak.
Dagql ships a few standard ones in dagql/cache_inputs.go:
PerClientInputPerSessionInputPerCallInputPerSchemaInputCurrentSchemaInputRequestedCacheInput(argName)These cover most of the "boring but necessary" cases.
PerClientInputAdds the current client ID as an implicit input.
Use this when results must not cross clients even within the same session.
PerSessionInputAdds the current session ID as an implicit input.
Use this when reuse within a session is fine, but reuse across sessions is not.
PerCallInputAdds a random value as an implicit input.
This forces a unique identity per invocation, which is effectively "always miss the cache."
CurrentSchemaInputAdds the current dagql server schema digest as an implicit input.
This is important for fields whose meaning depends on the currently served schema. currentTypeDefs is the most obvious example.
RequestedCacheInput(argName)This is a neat helper for the common noCache pattern.
It uses the value of a boolean arg to choose between:
So the field still uses the cache model, but the caller can request "treat this like uncached" without inventing a completely separate execution path.
Implicit inputs are first-class in the identity model.
They live in:
ResultCall.ImplicitInputscall.ID.ImplicitInputs()When the recipe ID is rebuilt, implicit inputs are appended separately from normal args with:
call.WithArgs(...)call.WithImplicitInputs(...)They affect the digest, but they are still carried as separate structured inputs.
There is also a test showing two calls with different implicit inputs produce different digests: TestImplicitInputsAffectDigest.
One small nuance: implicit inputs are intentionally omitted from the human-readable display form of IDs for now, even though they do affect the digest.
The relationship is:
If all you need is "scope this field by client/session/schema/caller module," implicit inputs are the right tool.
If you need to:
then you want a dynamic input function.
Here are the most useful examples in the current codebase.
currentModuleQuery.currentModule uses a dynamic input hook in core/schema/module.go.
It injects an internal implementationScopedMod arg when the caller did not provide one. That arg is derived from the current module, but specifically from its implementation-scoped identity rather than its caller-specific provenance.
Why this matters:
This is a good example of dynamic input as "rewrite the recipe to the real identity you wanted all along."
cacheVolumeQuery.cacheVolume uses a dynamic input hook in core/schema/cache.go.
It does two interesting things:
namespace was omitted, it derives a namespace from the current module and injects it as an internal arg.PRIVATE, it injects a random privateNonce arg so the result becomes unique.So this hook both:
withMountedCacheContainer.withMountedCache uses a dynamic input hook in core/schema/container.go.
It loads the referenced cache volume, merges any overriding source, sharing, and owner inputs, resolves ownership if needed, then rewrites the cache arg to the canonical resolved cache volume.
This is one of the clearest examples of why the hook exists:
ModuleFunction.DynamicInputsForCall is another important example.
It injects object-valued defaults that the caller did not explicitly provide, including:
defaultPath.envThis is not just execution-time convenience. These values are pushed into req.Args before cache lookup, so they become part of the recipe identity too.
That means two calls that omit an arg but resolve to different contextual/default objects do not accidentally collide in cache.
Container.from uses a custom implicit input named fromSessionScope.
Its rule is:
That is a nice example of an implicit input depending on explicit args. Digest-addressed refs are immutable and safe to share broadly. Tag-based refs are mutable, so they are only cached within a session.
noCacheSeveral host accessors use:
WithInput(dagql.RequestedCacheInput("noCache"))That gives them a simple "cache normally unless the caller asked not to" behavior without inventing opaque identity hacks.
Host.service uses:
dagql.PerSessionInputcore.CachePerCallerModuleThat combination means:
CachePerCallerModule is a custom implicit input that resolves to the caller module's implementation-scoped content digest, or "mainClient" when there is no current module.
This is a good example of implicit inputs being composed, not just used one at a time.
There are many fields that just use the built-in helpers directly:
PerClientInput for client-local results like host unix sockets, tunnels, workdir access, and similar client-bound viewsPerSessionInput for session-bound operations like some HTTP and LLM-related callsPerCallInput for one-shot behavior like SSH auth socket scoping or explicitly per-invocation operationsCurrentSchemaInput for schema-shaped values like currentTypeDefsThese are boring in the best way: simple, declarative, and easy to audit.
The main design win here is that the extra cache scoping information is modeled explicitly.
Instead of "the digest changed for some mysterious custom reason," we can inspect:
in the structured call frame.
That is much easier to debug than directly salting the digest with hidden state.
This is conceptually similar to the older custom cache-config hook, but the current model is better in an important way.
The old style could make the recipe digest change in ways that were effectively opaque. The current design still lets the engine compute extra information dynamically, but it models that information as:
CallRequest when the concern is cache behavior rather than recipe identitySo the cache behavior is still dynamic, but the data model stays understandable.
If you are adding or reviewing a field:
WithInput(...) when the only need is declarative scopingThat last rule is easy to miss, and it is one of the most important details in the implementation.