Skip to content

Add request-scoped DynamicInstructions support - #273

Draft
qoli wants to merge 2 commits into
huggingface:mainfrom
qoli:codex/dynamic-instructions-upstream-draft
Draft

qoli wants to merge 2 commits into
huggingface:mainfrom
qoli:codex/dynamic-instructions-upstream-draft

Conversation

@qoli

@qoli qoli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

@mattt — opening this as a draft early to align on the API shape, landing order, and how you would prefer it split before I invest in polishing it for merge.

Intent

This adds a Foundation Models 27-shaped DynamicInstructions API for request-scoped instructions and tools. The same design is already exercised end to end in a downstream stack, but this PR is deliberately not ready to merge yet.

The proposed behavior is:

  • DynamicInstructions supports builder composition, conditionals, collections, tools, and type erasure.
  • LanguageModelSession.init(model:dynamicInstructions:history:) reevaluates the body immediately before every model request, including tool-continuation requests.
  • A tool call is executed against the exact tool snapshot that produced that call.
  • Dynamically projected instructions are not persisted into the session's durable transcript history.
  • Built-in adapters consume a resolved request context; provider wire formats do not need to know about DynamicInstructions itself.

Base and current overlap

This draft is rebased on current main at 6da8838 (0.14.1). In particular, it preserves the Anthropic blank-text filtering merged in #271.

#264 still overlaps several provider adapter files. I am intentionally opening this before choosing a final merge/rebase strategy so we can avoid optimizing the series around assumptions that may change when #264 lands.

If the overall direction looks useful, a possible mini-PR sequence is:

  1. request-context resolution plus the mechanical adapter migration;
  2. the DynamicInstructions builder/value surface and API compatibility coverage;
  3. session reevaluation and tool-snapshot semantics, with behavioral tests and documentation.

I am happy to reorder or reshape that split based on your preference.

Downstream evidence

The earlier downstream version shipped through:

  • qoli/AnyLanguageModel@fde42a4
  • qoli/AIReasoningCore@68c012c
  • qoli/SwiftChat@56cef0b

That integration covered a same-session transition from Context A / Tool A to Context B / Tool B while preserving the prior transcript, including the tool-continuation path.

Known design questions / non-goals

  • Foundation Models 27 also offers a default-model initializer. This draft currently requires an explicit model; I would like to settle whether parity or the existing AnyLanguageModel convention should win before changing that surface.
  • SessionProperty / DynamicProfile-style state ownership is intentionally not included here.
  • I have not tried to hide provider-specific behavior behind fallback logic.

Local verification

  • swift format lint --strict --recursive .
  • git diff --check
  • CI=1 swift test — 608 tests across 61 suites, 0 failures
  • iOS Simulator build
  • watchOS Simulator build-for-testing

No live provider calls were made. The full upstream toolchain / Linux / traits matrix is left to GitHub Actions.

@qoli

qoli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Design note 1: why I modelled this as a per-request snapshot

The invariant I started from was: the app should be able to change the instructions and tools for the next request without changing the identity of the LanguageModelSession or rewriting its conversation history.

I considered mutating session.tools / instructions in place, adding a setTools-style API, and rebuilding the session whenever application state changed. I rejected those approaches because they make the request boundary ambiguous: an in-flight response could observe instructions from one state and tools from another, and rebuilding the session pushes transcript restoration and response-lifecycle coordination onto every caller.

That led me to treat a session as two different kinds of state:

  • durable state: model identity and the conversation transcript;
  • request-scoped state: the instructions and tool set projected from the app's current context.

resolvedRequestContext() is the seam between them. It creates one immutable (transcript, instructions, tools) view immediately before a provider or local-model request. For an ordinary static session, that view is exactly the existing session state. For a dynamic session, the resolved instructions are prepended only to the request transcript; they do not mutate the durable transcript.

This is also why history: deliberately excludes a dynamic instructions entry. When a conversation is restored later, its durable exchanges remain intact, while the next request is projected from the current app state rather than a stale environment captured when the history was saved.

@qoli

qoli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Design note 2: the tool snapshot is a causality rule, not just an implementation detail

The subtle case for me was a tool continuation. Suppose request A is sent with Tool A available, and the model returns a call to Tool A. Before the continuation request, application state changes and the dynamic body now resolves to Tool B.

I think two different moments need two different rules:

  1. The generated call must execute against the exact tool snapshot that produced it — Tool A in this example. Looking it up in the newly resolved tool set could execute a different implementation, or incorrectly report that the requested tool no longer exists.
  2. After that tool output is recorded, the continuation is a new model request, so the dynamic body should be evaluated again and the continuation should see Context B / Tool B.

In other words, the request context provides causal consistency for one provider turn; it is not a cache for the whole response loop.

The behavioral tests exercise this for both streaming and non-streaming paths. They assert that the model sees A and then B, while the executed tool is still A. The failure and cancellation cases also check that a completed dynamic-tool side effect is not replayed on a later retry. I included those cases because a design that works only for the happy-path request/continuation sequence would not be safe enough for session orchestration.

@qoli

qoli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Process note: why I proved the full path first, but do not expect this to land as one large PR

I implemented this downstream-first because the main uncertainty was semantic rather than syntactic: would the same-session model still behave correctly through real orchestration, tool execution, persistence, and restoration? I first exercised the design across AnyLanguageModel, AIReasoningCore, and SwiftChat, including the Context A / Tool A → Context B / Tool B transition. That gave me evidence for the ownership boundary before asking upstream to commit to the public API.

The resulting draft touches many built-in adapters because each adapter currently chooses where to read session.transcript and session.tools. Most of that diff is mechanical request-context plumbing; I do not take the 18-file shape as evidence that it should be reviewed or merged atomically.

My current decomposition is only a proposal:

  1. establish request-context resolution and migrate adapters without changing static-session behavior;
  2. add the DynamicInstructions builder/value surface and compatibility coverage;
  3. wire the dynamic session semantics, tool-snapshot behavior, tests, and documentation.

There are reasons to reorder that series, especially if the public API should lead the implementation, so I would rather get maintainer guidance before manufacturing a stack of PRs. #264 also changes reasoning/transcript construction in several of the same adapters; waiting for its direction avoids repeatedly rebasing mechanical code and obscuring its semantic changes.

Two surface questions remain intentionally open in this draft:

  • whether AnyLanguageModel should mirror Foundation Models' default-model dynamic initializer or continue requiring an explicit model;
  • whether RequestContext should remain an exposed provider-integration seam or be narrowed after the built-in adapters converge on it.

I have intentionally left broader state-ownership concepts such as SessionProperty / DynamicProfile out of scope. The smallest useful feature here is reevaluating instructions and tools at the request boundary; it does not require AnyLanguageModel to own application state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant