Skip to content

Add session properties and Foundation Models 27 HistoryView compatibility - #313

Open
qoli wants to merge 13 commits into
huggingface:mainfrom
qoli:codex/upstream-session-properties
Open

qoli wants to merge 13 commits into
huggingface:mainfrom
qoli:codex/upstream-session-properties

Conversation

@qoli

@qoli qoli commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Scope

Session-scoped properties for dynamic instructions and Tools, following Foundation Models 27 while retaining OS 26 deployment support.

  • HistoryView is a value-type struct with the SDK collection contract, Hashable/Strideable Index and self-typed subsequences.
  • Preserve the original 1.0 public RequestContext and zero-argument resolvedRequestContext(). Its Tools retain original concrete types/instances; ordinary-import regressions check type casts and reference identity.
  • Tool.SessionProperty belongs to this PR. Binding happens at execution/native boundaries, including detached execution, without inherited TaskLocals.
  • Active Tool/dynamic-instructions history is read-only; custom values remain writable and session-isolated. Defaults are evaluated outside the storage lock and rechecked before storage; no extra Sendable constraint on SessionPropertyKey.Value.

Upstream 1.1 synchronization

Normal history-preserving merges synchronize upstream main 7d43219, including #357 per-request usage accounting, #341 Ollama reasoning, #358 host-scoped Chat Completions reasoning, #300 response-start timeouts, #362 cancellation collection, #364 partial structured snapshots and #366/#367 bounds/reasoning/system-Tool fixes. Earlier #336 final-request usage, #340 HTTPTransport, #297/#325/#331/#343/#352/#354/#356 remain intact. No force-push, new checkout, replacement PR or extra fallback.

The original public RequestContext/raw-Tool identity and explicit detached/native session-property bindings remain unchanged. Upstream's request-usage TaskLocal is accounting only, not a substitute for explicit Tool property binding.

Fresh local verification (2026-10-23)

Head 2495f61. CI=1 GIT_CONFIG_GLOBAL=/dev/null swift test: 906 tests / 97 suites passed. The separate upstream macro test target also passed 2 tests / 1 suite. Strict formatting/diff checks passed. These are compile/offline tests, not live API, model-weight or Apple Intelligence inference acceptance.

Profile composition remains separate in #314. The new profile/runtime fields, overloads, hooks and constructor remain Compatibility SPI in #315, pending the existing construction decision.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Observation notifications, history filtering, and request snapshot consistency need correction.

3 open findings
What changed in this PR

Adds Foundation Models 27-compatible session properties and binds them during dynamic-instruction evaluation.

Changes:

  • Adds session-property storage, wrappers, and declaration macro.
  • Exposes mutable, instruction-free session history.
  • Adds behavioral and cross-framework compatibility tests.
File Description
SessionPropertyTests.swift Tests properties, history, and session isolation.
APICompatibilityFoundationModelsTests.swift Checks Foundation Models 27 compatibility.
APICompatibilityAnyLanguageModelTests.swift Checks matching AnyLanguageModel usage.
SessionPropertyEntryMacro.swift Generates property keys and accessors.
Plugin.swift Registers the new macro.
SessionProperties.swift Defines the session-property API and storage.
LanguageModelSession.swift Binds properties and connects history to sessions.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/AnyLanguageModel/LanguageModelSession.swift Outdated
Comment thread Sources/AnyLanguageModel/LanguageModelSession.swift Outdated
Comment thread Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

History accepts instruction entries, the key value constraint is incompatible with the mirrored API, and public documentation is stale.

0 open findings

3 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Constrain SessionPropertyKey.Value to Sendable

Sources/​AnyLanguageModel/​SessionProperties.swift:17

SessionPropertyKey.Value is missing Foundation Models 27's Sendable constraint. This allows a non-Sendable value to be stored in SessionPropertyValues, even though that container is exposed as @unchecked Sendable, and makes this protocol's public surface incompatible with the API it mirrors. Constrain the associated value type to Sendable.

Medium severity Filter instruction entries from writable HistoryView

Sources/​AnyLanguageModel/​SessionProperties.swift:54

The public HistoryView is an unrestricted [Transcript.Entry], so this setter currently accepts .instructions entries even though history is documented and exposed as instruction-free. Such entries remain in session.transcript but disappear from the next history read, and can duplicate preserved static instructions. Filter instruction entries here just as the dynamic-session initializer does.

Low severity Document session mutation during request context resolution

Sources/​AnyLanguageModel/​LanguageModelSession.swift:180

Binding writable session properties makes resolvedRequestContext() observably mutate the session when dynamic instructions update a property (as the new revision test demonstrates), so its existing API documentation—“Calling this method doesn't change the session”—is now false. Update that contract to describe the permitted property mutation.

🧠 Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

History binding can cross session boundaries, and history replacement can retain unwanted instructions.

0 open findings

Previously missed (2)

In code that hasn't changed since last review

Medium severity Filter instruction entries from incoming history

Sources/​AnyLanguageModel/​LanguageModelSession.swift:341

HistoryView is an unrestricted [Transcript.Entry], so replacing history with Array(otherSession.transcript) can persist that session's .instructions entries. The ordinary history getter hides them, but dynamic resolution snapshots the full transcript, exposing those entries through bound history and sending them alongside the newly resolved instructions. Filter .instructions out of incoming history before combining it with the preserved static-instructions prefix.

Medium severity Scope task-local history to the bound session

Sources/​AnyLanguageModel/​SessionProperties.swift:55

The task-local history is applied to every SessionPropertyValues instance, not just the bound session. While session A's dynamic instructions run, reading sessionB.properties.history returns A's snapshot, and replacing B's history incorrectly triggers the read-only precondition. Check SessionPropertyBinding.values === self before applying either the snapshot or the write restriction so unrelated sessions remain isolated.

🧠 Review effort: Balanced

@qoli

qoli commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both additional history findings from the latest review in 3ad28fb: task-local history is now scoped to its owning SessionPropertyValues instance, and incoming instruction entries are filtered before persistence. Added regressions for reading/writing a second session from a read-only instructions evaluation and replacing history from another session transcript. swift test --filter SessionPropertyTests passes 11 tests; strict format lint passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Dependent defaults can deadlock, and history replacement can leave in-flight responses attached to the wrong conversation.

2 open findings

🧠 Review effort: Balanced

Comment thread Sources/AnyLanguageModel/LanguageModelSession.swift
Comment thread Sources/AnyLanguageModel/SessionProperties.swift Outdated
@mattt

mattt commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @qoli, for all the work on this stack, and for keeping up with every round of review.

I'd like to get as much of this as we can into a 1.x release. The catch is that anything public in 1.x has to stay as it is until 2.0, so where Foundation Models 27 has an API, ours should have the same shape. A few changes would get us there:

  1. Could Transcript.HistoryView be a struct, like Apple's, conforming to the same collection protocols (RandomAccessCollection, MutableCollection, RangeReplaceableCollection, ExpressibleByArrayLiteral)? As a typealias for [Entry], code can rely on it being an array, and then it can't change later without breaking that code.
  2. The functions that only the providers call — resolvedRequestContext(including:options:), profileDidProduceEntries(_:requestContext:currentRoundEntries:), withProfileToolExecution(requestContext:currentRoundEntries:operation:), and the others like them — could go behind @_spi(Compatibility), the way Preserve attachments in tool outputs #312 does it.
  3. Could you split Add Foundation Models 27 dynamic profile composition APIs #314 and Add shared profile execution behind Compatibility SPI #315 into smaller PRs? For example, the profile API first, then the tool-loop changes, one provider at a time. Together they're about 5,600 lines across five providers, which is a lot to review at once. It'd also help us see which parts depend on how a session is constructed — Roadmap: 1.0 (Foundation Models 26) and 2.0 (Foundation Models 27) #210 expects that to change in 2.0, so those parts may need to wait for it.

One question: what happens when a tool that uses @SessionProperty runs with MLX, llama.cpp, or the system model? From reading the code, it looks like it might crash there.

@qoli qoli changed the title Add session properties to dynamic instructions Add session properties and Foundation Models 27 HistoryView compatibility Oct 17, 2026
@qoli

qoli commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@mattt I’ve reshaped the stack around your review requests, preserving the existing branch histories (normal merges/commits, no force-push).

The provider integrations are separate sibling PRs:

PR Scope Dependencies
#344 OpenAI Chat Completions + Responses #315
#345 OpenResponses #315
#346 Anthropic #312, #315
#347 Gemini #312, #315
#348 Ollama #315
#349 Native history projection only #315

#349 does not add native Tool delegate/callback forwarding, so it stays separate from #297. Anthropic’s ordinary non-streaming, single-request Tool behavior is preserved; automatic continuation is profile-only. Upstream #343’s repeated-call behavior is preserved throughout.

The PR descriptions include dependency notes and isolated provider-review links. #317 remains draft: its retained public-construction example needs the construction contract settled before publication.

Integrated local verification on the dependency aggregate: 851 default tests; 1,017 with MLX/Llama/CoreML; 745 with AsyncHTTPClient. watchOS device build and watchOS Simulator build-for-testing also passed. Live weights / Apple Intelligence generation were not exercised. The current #313 workflow is awaiting maintainer approval, rather than reporting a code/test failure.

A final default build/test run also passed in a fresh SwiftPM scratch directory. Switching AsyncHTTPClient off in the reused .build had left importable AHC artifacts in the product search path (canImport(AsyncHTTPClient) in EventSource), causing inactive C-module resolution errors; isolation confirmed this was build-product contamination. No dependency source or lockfile changes were committed.

@mattt

mattt commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @qoli. The split makes this much easier to follow, and HistoryView looks right now. A few things from going through it:

  1. 313: RequestContext and resolvedRequestContext() were already public in 1.0 — so models outside the package can read their inputs — and putting them behind SPI would break those models. Could you keep them public and put only the new overloads and hooks behind @_spi(Compatibility)? While you're there, Tool.SessionProperty could move into 313 from Add shared profile execution behind Compatibility SPI #315.
  2. 315: let's make init(profile:history:) public, with Apple's signature, so the profile types from Add Foundation Models 27 dynamic profile composition APIs #314 are usable in 1.2. Let callers set how many tool rounds a response takes #331 is about to land, so the round limit here will need to read options.maximumToolRounds.
  3. 314: Apple's historyTransform doesn't take a @Sendable closure. Could ours match, so the same code compiles against both?
  4. 344, 345, 346, 348: the replacement helpers drop entries whose ID is already in the transcript, and the tool output ID is the provider's call ID. If a server reuses call IDs across turns — call_0 every time, say — a request can silently lose that round's tool outputs, and that's for everyone, not only profile users. Could you move one shared helper into Add shared profile execution behind Compatibility SPI #315, keep transcript + inFlight when there's no profile, and add a test with the same call ID in two rounds that checks the second request? In Integrate dynamic profiles with Anthropic Tool loops #346, the in-flight messages are also built and encoded when there's no profile.
  5. 348: making non-streaming Ollama continue after tool calls is probably the right fix, but it changes things for everyone using Ollama. Could you move it into its own PR, so we can look at it alongside how Integrate dynamic profiles with Anthropic Tool loops #346 handles Anthropic?

@qoli

qoli commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, @mattt — items 1 and 3 are now addressed:

Both branches now include main 371b142 via normal merges. Fresh full SwiftPM runs passed: 815 tests / 85 suites for #313, and 822 / 86 for #314. The regressions were confirmed to fail to compile before their respective fixes. The remaining runtime/provider items are still being worked through separately.

@qoli

qoli commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on item 4 of #313 (comment): the common mapper is now in #315, and #344/#345/#346/#348 preserve the ordinary transcript + inFlight path. New regressions reuse the same call ID across rounds and turns and check the continuation requests. Anthropic's ordinary nonstreaming branch no longer builds/encodes unused in-flight messages.

All provider heads have been normally merged with the updated dependencies and main 303f8a1, verified, and pushed. The detailed shared fix and per-PR validation are in #315 and the provider descriptions. #317 remains draft; its integrated checks pass 930 tests / 92 suites by default and 798 / 83 with AsyncHTTPClient from an isolated scratch build.

Items 1 and 3 remain covered by the earlier public-API/Tool.SessionProperty and non-Sendable historyTransform fixes. Item 2's public profile initializer and item 5's separate ordinary Ollama PR are still outstanding, not silently treated as addressed.

@mattt

mattt commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @qoli. The shared continuation helper and the round-scoped IDs look good, and they fix the lost tool outputs. A few things left:

  1. 313: resolvedRequestContext() now returns the tools wrapped in SessionPropertyTool, so code outside the package that checks tool as? MyTool would get a different answer than in 1.0. Could the binding happen somewhere else, so it returns the tools as given?
  2. 315: init(profile:history:) is still SPI. Let's make it public — the signature already matches Apple's. What should happen when a profile doesn't set a model? Right now, the first request throws.
  3. 315: now that ToolRoundLimit is a class, every var toolRounds gives a warning. Could those be let? MLX and Llama also don't use session.toolRoundLimit(for:options:), so they don't share the session's round budget. Could they?
  4. 344-347: these need Add shared profile execution behind Compatibility SPI #315's latest head merged in. Also, resolveToolCalls makes a temporary ToolCalls entry, so profile hooks and tools see a ToolCalls entry ID that never shows up in the transcript. Could they get the committed entry instead?
  5. 348: could the non-streaming Ollama continuation go in its own PR, as I asked before?
  6. 349: a question: the Foundation Models adapter calls resolvedRequestContext() again inside its body. If Foundation Models evaluates that outside the request, does the profile get resolved twice?

@qoli

qoli commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, @mattt. I reproduced and addressed items 1, 3, 4 and 6 with normal dependency merges:

  1. Add session properties and Foundation Models 27 HistoryView compatibility #313 9b01280: public RequestContext.tools returns the original Tool values/instances. Session-property wrappers are applied only at execution/native boundaries. An ordinary-import regression first reproduced concrete-type and reference-identity failures; the full suite now passes 841 / 88.
  2. Add shared profile execution behind Compatibility SPI #315 57094db: ToolRoundLimit references are let; MLX/Llama use the shared session limit and accumulated recorder. Default verification passed 881 / 91, and a separate MLX,Llama,CoreML scratch build passed 1047 / 101 in CI mode.
  3. Integrate dynamic profiles with OpenAI Tool loops #344–Integrate dynamic profiles with Ollama Tool loops #348: each now includes Add shared profile execution behind Compatibility SPI #315 57094db and creates the complete ToolCalls entry before hooks/Tool execution, then commits that same entry. The strengthened fixtures compare the entire entry, including ID and provider metadata, and first failed on the old implementation. Fresh full results: Integrate dynamic profiles with OpenAI Tool loops #344 8e6b0a6 — 889/91; Integrate dynamic profiles with OpenResponses Tool loops #345 ce17d35 — 889/91; Integrate dynamic profiles with Anthropic Tool loops #346 06808c7 — 898/91; Integrate dynamic profiles with Gemini Tool loops #347 b7a08d0 — 897/91; Integrate dynamic profiles with Ollama Tool loops #348 888257b — 889/91. OpenAI assistant-content metadata and Gemini thought signatures are preserved. Anthropic ordinary non-streaming remains a single request/return; Ollama still honors the producing request's offered-Tool list.
  4. Project profile history into native Foundation Models sessions #349 30dd238: yes, the old factory could resolve a profile twice while constructing history and evaluating native instructions. Regressions reproduced that and detached evaluation without TaskLocal inheritance. The initial adapter evaluation now consumes the context already used for history; subsequent evaluations resolve afresh. Ordinary dynamic-instructions timing is unchanged. Full verification: 885 / 92, without Apple Intelligence inference.

Constructor visibility/default-model policy and the separate ordinary Ollama continuation PR are still pending scope confirmation; the constructor remains SPI, a missing model still reports the existing explicit error, and no new default model or PR was introduced.

The draft aggregate #317 6bde2c0 contains every latest dependency head. Fresh integrated runs passed 933/92 (default), 801/83 (AsyncHTTPClient) and 1099/102 (MLX/Llama/CoreML), using separate trait scratch paths in the same checkout. These are compile/offline checks, not live API/model-weight acceptance. The original independent README delta remains 64 lines and the PR stays draft.

@mattt

mattt commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks for items 1, 3, 4, and 6, @qoli. For the other two:

  • 315: Yes, let's make init(profile:history:) public, with Apple's signature and no default model. Foundation Models falls back to the system model, but everywhere else we require a model, so let's keep that here and say so in the doc comment. Could the check happen before onActivate and onPrompt run, with an error that says to add .model(_:)?
  • 348: Could the non-streaming Ollama continuation go in its own PR?

@qoli

qoli commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for clarifying the no-default-model and early missing-model validation requirement.

Public construction (including that preflight validation) and the standalone Ollama non-streaming PR remain outstanding in this update: init(profile:history:) stays Compatibility SPI, no default model is selected, #348 retains its existing behavior, and #317 stays draft. I have not marked those two scope items done.

Separately, all existing implementation branches and the draft aggregate are now synchronized with main 7d43219 through normal history-preserving merges:

Fresh local CI-mode verification of aggregate b946bec: default 1009 tests/101 suites, AsyncHTTPClient 851/91, MLX+Llama+CoreML 1175/111; the new upstream macro-test target separately passed 2 tests/1 suite in each configuration. #315 also passed default 949/100 and local traits 1115/110. These are compile/offline checks, not live model/API acceptance.

The aggregate contains all ten implementation heads, provider files match their owning PRs, and the original independent profile README delta remains 64 added lines. PR descriptions include each latest head's verification.

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.

3 participants