Skip to content

Settings and stores ​

Typeshift stores each agent's Settings under a settings id, and conversations under a conversation id. Settings go through an ISettingsStore and conversations through an IConversationStore, which you implement with the id type you already use, in one class or in two.

Settings layering ​

A run's settings come from four layers, and each sets values over the one below. Tool settings, chat options and run options all resolve this way, once, when the run starts.

One value, four layers ​

Follow the find_companies limit of the getting started agent up through the layers:

1. The code's defaults. FindCompaniesSettings initializes Limit to 5, so a record made with its parameterless constructor holds 5. This layer sets every member, so a tool always receives a whole record.

2. The agent's code. ResearchAgent calls ConfigureFindCompanies().WithLimit(3), so every run of the agent searches with 3 unless a layer above sets another limit. Its chat and run options, such as WithTemperature(0.2f), live here too. Because this layer is reviewed, compiled code, it may set any settable member, configurable or not.

3. The stored settings. ConfigureAsync(42, ...) stores Limit = 2 and Temperature = 0.4f, so every run with settings id 42 searches with 2, at a temperature of 0.4. IncludeInactive cannot be stored, because ResearchAgent.Settings has only the configurable members.

4. The run. A run that passes FindCompanies = new() { Limit = 1 } in its settings searches with 1, for that run only. It leaves Temperature null, so the stored 0.4 holds.

Precedence ​

The highest layer that sets a value wins:

LayerSet byMay setHolds for
4. The runThe settings argument of RunAsync and the other runsConfigurable membersThat run
3. StoredSettings under the run's settings id: ConfigureAsync, SaveSettingsAsyncConfigurable membersEvery run with that settings id
2. The agent's codeConfigure{Tool}(), WithChatOptions(), WithRunOptions(), GetInstructions()Every settable memberEvery run of the agent
1. The code's defaultsThe settings record's parameterless constructor; the run options' defaultsEvery memberEvery run

Because the layers resolve when a run starts, settings saved during a run apply from the next run on.

Null values and nested records ​

A layer either replaces a value below it or leaves it alone. It never merges, so a configurable member that is itself a record or a class is replaced as a whole.

No layer clears a value

Null keeps the layer below, so neither stored settings nor a run can take away a temperature the agent's code set.

The model and the instructions ​

The model resolves through the layers like any other value. It stays as the code sets it unless Configurable(o => o.ModelId) lets the layers above choose it, so stored settings can change the model only when the agent's code allows that. Its provider is a value of its own, so a layer that sets only Model keeps the provider of the layer below.

Stored or run Instructions replace the text that GetInstructions() returns. Context providers and plan mode then append to whatever instructions the layers resolved.

The mode of a conversation ​

A run with a conversation id continues in the mode (Default, Plan or Auto) that the conversation's last run ended in. That mode overrides the agent's code and the stored settings, but not the run's own settings. A run whose own settings name a mode switches the conversation to it, and so does an approved plan (The three modes).

Sub-agents and handoffs ​

Sub-agents and handoffs keep the settings id, but not the run's settings. A sub-agent runs with its own settings stored under the caller's settings id, in the caller's mode. An agent handed off to answers with its own stored settings under the same id. Run settings apply only to the agent the run started on (Multiple agents).

Registering a store ​

Register the store in the AddTypeshift() chain:

cs
var services = new ServiceCollection();

services.AddTypeshift()
    .WithStore<InMemoryTypeshiftStore>()
    .WithChatClientProvider<KeyedChatClientProvider>();

services.AddKeyedSingleton<IChatClient>("openai", new CannedChatClient());

await using var provider = services.BuildServiceProvider(validateScopes: true);

snippet source | anchor

InMemoryTypeshiftStore keeps everything in memory and serves tests and samples. For your database, write a store of your own: Implementing a store covers its methods, its versions and the conversation summaries it reads.

WithStore<T>() registers each store interface your class implements, so an ITypeshiftStore takes one call. A store of settings and a store of conversations take one call each.

The store is a singleton

WithStore<T>() registers the store as a singleton, so one instance serves every run at the same time. It cannot take scoped services, such as a DbContext: Register your store shows how to build one around that.

Without a store, agents run from their code alone. A run that passes neither a settings id nor a conversation id works, tools included. Every call that loads or saves, such as a run with either id or ConfigureAsync, throws an InvalidOperationException that names WithStore<T>() and the interface no registered store implements.

Agent settings ​

A settings id stands for whatever you decide: a customer, a team, a user, or a settings profile of your own. Under one settings id, each agent has its own document, because each agent's Settings is its own kind, so a store keys documents by id and kind together.

ConfigureAsync loads an agent's settings, applies your change and saves them. When another save came in between, it loads them again and reapplies your change, ten tries in all. If the tenth save loses too, it throws the store's StoreConflictException:

cs
await support.ConfigureAsync(settingsId, settings => settings with
{
    Provider = "anthropic",
    Model = "claude-sonnet-5-5",
    Instructions = "Answer in German.",
    SearchTickets = settings.SearchTickets with { Limit = 20 }
});

snippet source | anchor

A settings form does it in two steps: it loads the settings with their version and saves them back with it. The version tells the store which state the form loaded, so the save throws a StoreConflictException when someone saved in between (Save with a version explains the number):

cs
var stored = await support.LoadSettingsAsync(settingsId);

try
{
    await support.SaveSettingsAsync(settingsId, stored.Data with { Instructions = null }, stored.Version);
}
catch (StoreConflictException)
{
    stored = await support.LoadSettingsAsync(settingsId);
}

snippet source | anchor

Saving Instructions = null removes the stored instructions, so runs with this settings id use those of GetInstructions() again. After a conflict, the sample loads the settings again, so you can show the user the current values before they save.

The stored JSON ​

What is stored is the record's JSON: camelCase, with the tools nested under "tools" by name and nulls left out.

json
{
  "provider": "anthropic",
  "model": "claude-sonnet-5-5",
  "instructions": "Answer in German.",
  "tools": {
    "search_tickets": {
      "limit": 20
    }
  }
}

An agent whose WithSchemaVersion(n) raised its schema version above 1 also writes "$schemaVersion": n first. System.Text.Json writes and reads the same JSON, so an endpoint that binds SupportAgent.Settings accepts and returns the JSON the store keeps. A stored member that cannot be read, because its value has the wrong type or its name is unknown, counts as missing, so the layer below applies. When a configurable member is itself a record or a class, a stored value replaces the whole object, so members missing from its stored JSON take that type's defaults, not the values of the layer below.

A renamed member loses its stored value

A configurable member that is renamed or removed reads as missing, so its stored value no longer applies. When that matters, raise the agent's schema version with WithSchemaVersion(2) and migrate the documents of the older schema version (Migrate older settings).

Released under the MIT License.