Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
161114 stories
·
33 followers

Module Tracking in Swift Debug Info

1 Share

When your Swift program hits a breakpoint and stops so you can inspect it, the debugger’s expression evaluator has to find the exact Swift module your code was built from. Until now, that lookup wasn’t always precise. The upcoming Swift 6.4 release will include changes, begun in Swift 6.3, that address this by updating how the Swift compiler references explicitly-built Swift modules in debug info.

The majority of developers will automatically benefit from faster, more reliable debugging and smaller build products, without any modifications to their SwiftPM or Xcode projects.

For developers who maintain their own build systems using, for example, Bazel, Buck, or CMake, some adjustments may be necessary to take advantage of these changes.

This article explains how the debugger uses Swift modules. Next, it describes how Swift 6.3 changes the way modules are tracked in debug info to solve several problems with the previous representation. Finally, it shows how to adjust build systems to take advantage of the new representation and eliminate some build steps that are no longer necessary.

Swift modules and expression evaluation

LLDB’s standout feature is its powerful expression evaluator. Because LLDB embeds the Clang and Swift compilers, it can JIT-compile any valid source code and run it in the context of your application while stopped at a breakpoint. This includes not just calling code in your application, but also defining new data types, functions, and closures. Debugging features that are usually reserved for interpreted or JIT-compiled languages like JavaScript become available to ahead-of-time-compiled languages like C++ and, of course, Swift!

In order to JIT-compile user expressions that make use of data types defined in the debugged program, LLDB’s embedded Swift compiler needs to import the Swift modules defining those types. In a world before explicitly-built modules, LLDB would find the base name of the main module at the current breakpoint in the debug info and then kick off an implicit import of a module with that name. With a cold module cache this would launch an expensive compilation of that module and all its dependencies.

To illustrate this, let’s walk through a simple example:

(lldb) p myObj

Here myObj is just a local variable: LLDB can find its location in the debug info and resolve its type via reflection metadata. No need to bother the Swift compiler. Let’s make it more complex:

(lldb) p myObj.myComputedProperty

In this case, myComputedProperty is really a function call; in order to evaluate this, LLDB needs the expression evaluator to run code in the target. In order to initialize a Swift compiler instance with the state of the current module, LLDB finds the name of the current function’s Swift module in debug info. We can visualize what LLDB does using the dwarfdump utility:

$ dwarfdump Foo.o
...
DW_TAG_module
  DW_AT_name ("Foo")

Conceptually, LLDB then wraps the expression in a function that can be compiled:

(lldb) log enable lldb expr
(lldb) p myObj.myComputedProperty
...
import Foo

func lldb_expr(_ $__lldb_arg : UnsafeMutablePointer<Any>) {
  let myObj: MyObject = /* some LLDB magic */
  // Expression begins here:
  myObj.myComputedProperty
  ...

One problem with this is that import Foo is quite imprecise: Even though the Swift language doesn’t allow multiple modules to have the same name, even the most stringently engineered application may have more than one copy of the same module. For example, there might be a private version of a module containing all of its private declarations (which would be great for LLDB) and also a Swift interface file that only contains the public interface for the module. Or there might be macOS and Mac Catalyst variants of the same module in the same process.

Swift modules, debug info, and the build system

Let’s look at where those modules are found next. In order to communicate the location of Foo.swiftmodule to LLDB, Swift build systems rely on some cooperation from the linker. On Darwin the system linker accepts an option called -add_ast_path and build systems are expected to specify this option to list every binary Swift module when linking.

# Linker invocation on macOS
ld -add_ast_path /path/to/Foo.swiftmodule Foo.o -o MyApplication

The linker translates these options into symbol table entries. The debug info linker dsymutil then collects all Swift modules and stores them in a special __swift_ast section in the dSYM bundle, where LLDB can find them by name. Alternatively, when debugging without dSYM bundles, LLDB reads the symbol table entries in the binary to collect a list of all binary Swift modules. Such an approach would not work on platforms where the linker isn’t aware of Swift. For these platforms, which include Windows, Linux, and FreeBSD, the Swift compiler provides a -modulewrap action that takes a binary Swift module and outputs an object file with a .swift_ast section holding the contents of the module. This object file can then be passed to any linker to get added to the binary, where LLDB can find it.

# Modulewrap and linker invocation on Linux
swift-frontend -modulewrap Foo.swiftmodule -o Foo.swiftmodule.o
lld Foo.o Foo.swiftmodule.o -o MyApplication

This can create scalability issues, especially for large applications:

  • Module files can get large and for an entire application you can often end up with a large portion of the SDK in the resulting binary. That can be quite problematic for the binary size.
  • As mentioned above, the chances of LLDB finding the right module in a Swift AST section or symbol table just by its base name diminish as the application gets more complex.
  • Binary Swift modules are version-locked to the precise compiler that created them. This is at odds with the intent of dSYM bundles, which are meant for long-term archival serialization of debug info.
  • If a matching explicit module cannot be found, LLDB falls back to an implicit module import which may involve recompiling parts of the SDK from source. This can be very slow.

Precise module tracking

To evaluate expressions, the debugger needs to be able to find and import Swift modules. Until now, this relied either on special linker support or additional compilation steps, with a high cost for binary size. On top of that the debugger was imprecisely locating Swift modules by name.

Starting in Swift 6.3 and continuing since, we have been making changes to the Swift compiler, the Swift driver, and LLDB that improve performance, reliability, and scalability. These changes are built on top of explicitly-built modules.

What’s new

  • Explicitly-built modules track their explicit Swift dependencies: Explicitly-built binary Swift modules have always kept track of their explicitly-built Clang module dependencies. This is why LLDB can import explicit modules so much faster than implicit modules, which may need to recompile their dependencies from source. In Swift 6.3, explicitly-built binary Swift modules also keep track of their Swift module dependencies. This makes importing an explicitly-built module fast and unambiguous because no module needs to be looked up by name. This happens automatically. Users don’t need to make any changes. Users with distributed build systems will already be familiar with the Swift frontend’s path remapping options, which now also affect Swift module paths.

  • Debug info stores path of object file’s own Swift module: Once LLDB finds the top-level module it can precisely import it and all of its dependencies. But how can LLDB find precisely the module that belongs to the Swift file at the current breakpoint? In Swift 6.3, the Swift compiler can store the path to it in the debug info. Because a Swift file’s own Swift module is not an input to an object file compilation, there is a new -debug-module-path compiler option to communicate the path to each object file compilation action. This path is also subject to the standard path remapping options used by users with distributed build systems.

  • Swift driver passes module path to compile jobs: Users of swiftpm or Xcode do not need to think about this, because the Swift driver also knows about the new -debug-module-path option and automatically passes the path to the object file’s own Swift module to the compiler. However, users maintaining their own third-party build system to orchestrate Swift compilations with explicitly-built modules that are calling the Swift frontend directly and bypassing the Swift driver need to make sure to communicate the path to the top-level module to each object file compilation job.

What’s deprecated

Beginning in Swift 6.4, you can safely make the following changes.

  • swiftc -modulewrap and ld -add_ast_path: Because the module paths are now communicated via debug info and the module headers themselves, third-party build systems doing explicit module builds can now remove all -modulewrap actions on Linux and Windows; and remove the use of the -add_ast_path linker option on Darwin (macOS, iOS, etc…).

  • Binary Swift modules in dSYM bundles: As a consequence, dsymutil will no longer process binary Swift modules. This is a good thing, because binary Swift modules—which can only be parsed by the exact toolchain that produced them—were always at odds with dSYM bundles being a long-term archival format. Moreover, Swift modules often depend on Clang modules, and these Clang modules also were never included in dSYM bundles. By removing the binary Swift modules, dSYM bundles will get smaller.

    • But don’t we need them for debugging? Since Swift 1.0, binary Swift modules were included in dSYM bundles because they were needed to resolve the types of local variables. However, starting with Swift 5.6, LLDB could perform this operation by reading the reflection metadata in the binary. The absence of binary Swift modules in dSYM bundles does not affect LLDB’s ability to inspect the contents of variables or dump object descriptions with po. Binary Swift modules are still needed to evaluate complex expressions like function calls or computed getters. Expression evaluation continues to work as long as LLDB finds all binary modules in their original (or remapped) location. This is always the case when debugging a just-built binary on the same machine. If the absence of binary Swift modules in dSYM bundles creates an unforeseen problem with your workflow, please let us know, either on the Swift LLDB forum or by creating an issue on the bug tracker.

When compiling with caching enabled, all paths pointing to Swift modules and module debug info are content-addressable storage references, identified by content rather than file location, so everything described here also works transparently with compilation caching.

Coming in Swift 6.4: Faster bridging header import in LLDB

Beyond more reliable path tracking, Swift 6.4 will also speed up importing bridging headers, a step common enough across Swift projects that most developers will feel the difference.

Up to and including Swift 6.3, LLDB always compiles a bridging header from source, a step that can add noticeable time to debugging sessions that use one. In recent nightly development toolchains, LLDB can use the new precise explicit module information to import precompiled bridging headers and their explicit module dependencies directly. This makes debugging explicitly-built projects with bridging headers as fast and reliable as debugging fully modularized projects.

Summary

With these changes for explicitly-built modules:

  • Binaries built with debug info on Windows and Linux, and dSYM bundles on Darwin will get dramatically smaller, since they no longer contain any binary Swift modules (6.4+)
  • Contextual module imports in LLDB become more reliable due to precise tracking instead of by-name lookups
  • Certain performance cliffs around module importing in LLDB are eliminated (such as SDK module dependencies in dSYMs triggering implicit imports)
  • Developers maintaining their own build systems can remove support for -modulewrap actions and remove -add_ast_path from the linker flags, but may need to pass -debug-module-path to the compiler if they are not letting the Swift driver handle the frontend options
  • Finally, static archives were easy to overlook: projects that didn’t use -add_ast_path when linking them often had confusing debugging issues inside those archives as a result. This entire class of issues has been designed away.

tl;dr: -modulewrap and -add_ast_path are replaced by -debug-module-path. Debug info gets smaller and more precise.

Read the whole story
alvinashcraft
18 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Kubernetes v1.37: Native Histograms Graduates to Beta

1 Share

I'm excited to announce that native histogram support for Kubernetes metrics is graduating to Beta and is enabled by default in Kubernetes v1.37!

Native histograms (previously introduced as Alpha in Kubernetes v1.36 under KEP-5808) bring high-resolution, low-cardinality observability to Kubernetes metrics. By adopting Prometheus Native Histograms, Kubernetes components now expose latency and duration metrics with far greater accuracy while significantly reducing telemetry storage and scraping overhead.

Why move beyond classic histograms?

Since the early days of Kubernetes observability, duration and latency metrics (such as API server request latencies or scheduling durations) have relied on classic Prometheus histograms.

Classic histograms require metric authors to define a static list of cumulative bucket boundaries (le labels), such as 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10. While familiar, this approach introduces three major challenges:

  1. The Bucket Guessing Game: If a workload's latency profile changes, for example, shifting into microsecond ranges or experiencing long-tail tail latencies beyond the highest bucket, the histogram loses visibility. Specifying bucket boundaries upfront requires knowing the distribution before observing it
  2. High Cardinality & Storage Cost: With classic histograms, each bucket boundary is exported as a separate time series (_bucket{le="..."}). A histogram with 10 buckets across multiple labels multiplies the number of time series by 10, increasing memory consumption in Prometheus and inflating time series database (TSDB) storage costs
  3. Interpolation Error in Quantiles: Calculating percentiles using histogram_quantile() relies on linear interpolation between static bucket boundaries. When bucket spans are coarse, quantile calculations can suffer from significant estimation error

What are Prometheus native histograms?

Prometheus Native Histograms replace static user-defined buckets with dynamic, exponential buckets.

Instead of emitting a separate time series for every single bucket boundary, a native histogram is stored as a single time series containing a rich schema of positive and negative spans, zero thresholds, and exponential scaling factors.

  • High Resolution Automatically: Exponential buckets dynamically adjust to any value range — from nanoseconds to hours — without requiring pre-configured bucket boundaries
  • Up to 90% Fewer Time Series: By consolidating buckets into structured spans within a single time series, scraping and storage overhead are dramatically reduced
  • Accurate Quantile Calculation: Quantiles can be calculated with mathematical bounds on error (≃5% worst-case relative error under default settings) across the entire spectrum of observations

How native histograms work in Kubernetes

In Kubernetes, native histogram support is implemented directly inside the shared metrics subsystem (k8s.io/component-base/metrics).

Figure 1 illustrates how native histogram metrics are processed and exposed across Kubernetes components.

Diagram showing native histogram metric registration, exponential options configuration, and dual exposition flow in Kubernetes components

Figure 1. Native histogram processing and dual exposition flow in Kubernetes.

1. Dual exposition for zero breaking changes

A primary design requirement for KEP-5808 was zero disruption for existing observability stacks. When the NativeHistograms feature gate is enabled, Kubernetes components use dual exposition:

  • Classic buckets (h.Bucket) are still emitted alongside native spans. Existing Prometheus servers, dashboards, and alerting rules that rely on traditional text scraping or classic bucket labels continue to work unmodified
  • Native spans (h.Schema, h.PositiveSpan) are included in the same Protobuf payload for collectors that understand native histograms

2. Tuned default exponential configuration

When NativeHistograms is enabled, the k8s.io/component-base/metrics package automatically applies standardized exponential options to all histogram metrics:

  • BucketFactor: 1.1: Configures exponential buckets where each bucket is at most 10% wider than the preceding one. This guarantees a mathematically bounded worst-case relative error of at most ~5% for quantile calculations regardless of whether an operation takes 1 millisecond or 10 seconds.
  • MaxBucketNumber: 160: Caps the maximum number of buckets per histogram to 160. Following OpenTelemetry SDK recommendations for base-2 exponential histogram aggregation, this limit protects component memory usage even under extreme outlier distributions.

3. Broad component support

Because native histograms are integrated into component-base/metrics, all major Kubernetes control plane and node components inherit support automatically, including:

  • kube-apiserver (e.g., apiserver_request_duration_seconds, authentication/authorization metrics, validation latencies)
  • kube-scheduler (e.g., scheduler_plugin_execution_duration_seconds, scheduler_scheduling_algorithm_duration_seconds)
  • kubelet (node-level container runtime and pod lifecycle metrics)
  • kube-controller-manager and kube-proxy

How to scrape native histograms

The simple answer: upgrade to Kubernetes v1.37, and it works.

Because Kubernetes v1.37 enables NativeHistograms by default, your cluster is already emitting dual-exposition metrics. How you configure Prometheus to scrape native histograms depends on your Prometheus version:

1. Prometheus scrape configuration by version

  • Prometheus 3.0+ (Recommended): Use explicit per-job configuration in your scrape_configs rather than global flags (the global --enable-feature=native-histograms flag is deprecated in Prometheus 3.9+):

    scrape_configs:
     - job_name: 'kubernetes-apiservers'
     scrape_native_histograms: true
     always_scrape_classic_histograms: true # Recommended during transition
    

    You must read the caution in Migrating dashboards and alerts in the Native Histograms documentation. In summary: always set always_scrape_classic_histograms: true during your transition period. Without this setting, Prometheus will only ingest the native format and stop ingesting classic _bucket, _count, and _sum series. Setting always_scrape_classic_histograms: true ensures existing dashboards (histogram_quantile(..._bucket...)) and alerts continue to work while you migrate them to native histograms.

  • Prometheus 2.40 – 2.x: Enable Native Histograms globally by starting Prometheus with the feature flag:

    prometheus --enable-feature=native-histograms
    

    Note that in Prometheus 2.x, this is an all-or-nothing setting for all scrape targets.

2. Verify Protobuf dual exposition

Standard Prometheus text scraping (application/openmetrics-text or plain text format) only transfers classic buckets. When scrape_native_histograms is enabled, Prometheus automatically negotiates Protobuf format with Kubernetes endpoints.

You can verify that a Kubernetes component is exporting native histograms using curl with an Accept header specifying Protobuf. For example:

## THIS IS NOT SECURE. ONLY DO THIS IN A TEST CONTEXT.
curl --insecure \
 -H "Accept: application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited" \
 --header "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
 https://localhost:6443/metrics

When decoded, the returned MetricFamily for histogram metrics (like apiserver_request_duration_seconds) will contain both traditional bucket entries and populated schema / positive_span fields.

Querying native histograms in PromQL

Once native histograms are ingested into Prometheus, you can query them using standard PromQL histogram functions without needing static le bucket labels or _bucket suffixes:

# 1. Calculating P99 latency for a single target:
# Classic histogram (requires _bucket suffix):
histogram_quantile(0.99, rate(apiserver_request_duration_seconds_bucket[5m]))

# Native histogram (operates directly on the metric name):
histogram_quantile(0.99, rate(apiserver_request_duration_seconds[5m]))

# 2. Aggregating across multiple instances (e.g., all API servers):
# Classic histogram (requires sum by (le) to preserve bucket boundaries):
histogram_quantile(0.99, sum by (le) (rate(apiserver_request_duration_seconds_bucket[5m])))

# Native histogram (no grouping by le required!):
histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds[5m])))

With native histograms, functions like histogram_quantile() operate directly on the dynamic exponential spans inside the time series, producing highly accurate quantiles without static bucket interpolation error.

For official documentation on querying Native Histograms in PromQL, see:

Dashboard migration & rollback strategy

To safely transition your monitoring infrastructure to Native Histograms without breaking existing alerts or dashboards, I recommend a four-step migration workflow:

  1. Enable Both Formats: In your Prometheus 3.x scrape config, set scrape_native_histograms: true AND always_scrape_classic_histograms: true so both formats are collected safely during transition
  2. Migrate Queries: Update your Grafana dashboards and Prometheus alerting rules from classic quantile queries (histogram_quantile(..._bucket...)) to native histogram queries (histogram_quantile(...)), and replace references to classic _count and _sum series with histogram_count(...) and histogram_sum(...)
  3. Verify in Staging/Production: Validate that all dashboards and SLO alerts fire and graph correctly using the new native histogram queries
  4. Unlock ~10x Storage Savings: Once migration is complete, set always_scrape_classic_histograms: false. Prometheus will stop ingesting the static _bucket, _count, and _sum time series, reducing your histogram time series count by up to 90%!

Opt-out and rollback flexibility

Because native histograms are dual-exposed, using them is entirely opt-in from a collector perspective:

  • Instant Collector Rollback: If you need to stop ingesting native histograms, simply set scrape_native_histograms: false in your Prometheus job configuration. No Kubernetes restart is required, and Prometheus will immediately resume scraping only the classic format without data loss
  • Component Feature Gate Rollback: Administrators can also disable the feature gate on Kubernetes components using --feature-gates=NativeHistograms=false (requires component restart)

What's next & how to get involved

As native histograms progress toward General Availability (GA) in future Kubernetes releases, SIG Instrumentation will continue evaluating ecosystem readiness, performance characteristics, and long-term plans for eventually deprecating static classic buckets once native histogram adoption becomes ubiquitous across the monitoring community.

Acknowledgements

A huge thank you to contributors across SIG Instrumentation and component owners who collaborated on the design, implementation, testing, and review of native histograms in Kubernetes!

Read the whole story
alvinashcraft
45 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Experimentation: Just Try It!

1 Share

I’ve now been working in tech for 25 years, and in that time I’ve developed some wisdom. One theme I’ve discovered and blogged about repeatedly over the years is profound despite its simplicity:

In many cases, the best way to know whether something will work is to just try it.

I’ve written numerous posts making fundamentally the same point:

While I believe experimentation is usually the best approach, it’s not always practical to try things out — you might not have the time, resources, or expertise required to experiment. And sometimes your experiment might be flawed and talking to an expert or reading the spec or code might reveal hidden complexities you didn’t foresee.

Beyond that, unfortunately some systems weren’t designed to facilitate experimentation.

Experimentation vs. Trapdoors

In this short clip, Jeff Bezos talks about decision making and the importance of recognizing the difference between a “two-way door” decision and a “one-way door” (trapdoor) decision. Put simply: If a decision can be easily reversed, you should make it quickly and without too much thought, avoiding analysis paralysis. Just try it!

In contrast, making a trapdoor decision requires much more thought: when a mistake would be very costly or unrecoverable, it is worthwhile to invest significant energy in making sure that your first choice is the best one.

In my experience, there are relatively few trapdoor actions in the real-world: yes, it’s often worthwhile to “measure twice and cut once“, but the number of actions with unrecoverable outcomes is small and such circumstances are typically obvious.

Design for Experimentation

To the extent possible, designers should strive to build products and standards that facilitate experimentation, allowing easy recovery if the user makes a mistake.

Observability

An experiment is risky if the outcome cannot be determined.

One important characteristic of experiment-friendly designs is that the experimenter can determine the result of the experiment. In some cases, the user can directly observe the result– desktop publishing software got much easier to use with the invention of “print preview” and what-you-see-is-what-you-get (WYSIWYG). In others, the product needs instrumentation and telemetry to determine the outcome (e.g. “With the new compositor feature flag enabled, our crash rate increased by 4%).

Sometimes the best way to make an experiment safe is to allow observation of the outcome without actually making the change (e.g. “print preview” shows the outcome without actually wasting paper); some products offer a “simulation” mode.

Latency

An experiment is risky if undoing the experiment takes too much time.

For example, the Web Platform’s Strict Transport Security feature allows a site to announce that it is only loadable over HTTPS, refusing to load over HTTP. A site owner can even decree that browsers should “pre-load” this enforcement to protect every visitor’s first visit. The problem is that the browser’s HSTS Preload list only updates every few weeks, meaning a common mistake is that a site owner preloads their entire domain but quickly learns that some overlooked subdomains only support HTTP. Then they panic and beg the browser vendors for help, but it’s too late — it’ll be weeks before their domain can be removed from the preload list. Oops.

Timeline of misery (Browsers now ship even faster, but the time-to-recover is still very long)

The Web Platform’s HTTP Public Key Pinning feature was so often a source of self-inflicted outages that the feature was removed from Chrome entirely in version 72.

As a less extreme example, Microsoft Defender’s Network Protection Indicators feature allows an organization to block any “indicator” (domain, IP address, code-signing certificate or executable file’s hash). When the Security Operation Center adds a block against an indicator, Defender will prevent access to the resource. For example, if your IT department configures a Defender Custom Certificate Indicator to forbid use of Notepad++ inside your enterprise, attempting to download or run the installer will result in a block:

But what happens if the SOC administrator read some threat intelligence and naively decided to block s3.amazonaws.com via a network indicator? They would very quickly find that a huge number of websites that use Amazon S3 storage fail to load correctly, preventing their colleagues from loading mission-critical web applications. The SOC admin would immediately delete the errant indicator, but because changes to Defender’s indicators typically take two hours to propagate to all devices, they’ve just created an expensive outage.

Custom Indicators are an extremely powerful feature, but to experiment with them safely the admin cannot blindly “set it and forget it.”

Audit/ReportOnly Mode

An experiment is safe if running the experiment is harmless.

Defender’s Indicators feature allows the admin to specify that the indicator should run in audit mode. While an indicator is in Audit mode, the SOC will receive a notice if the indicator would have triggered in warn/block mode. To safely deploy an indicator, admins should always first deploy the indicator in audit mode and wait, watching for any alerts that indicate unexpected blocking. Only after a successful trial period should an IOC be set to warn/block mode.

System designers can design systems that encourage safe experimentation, for instance, by not allowing an Indicator to directly be set to block mode until an audit period has elapsed.

Audit/Report modes don’t just exist for Defender Indicators, of course. Web Platform features like Content-Security-Policy allow a report only mode where content that violates the directives results in a telemetry report that can be logged for validation before moving into enforcement mode.

Careful This Thing’s Loaded!

When it’s not possible to make decisions easily reversible, there should be prominent warnings to alert the user to the risk.

The HSTSPreload website requires that the admin undertake a number of technical steps, and warns (although arguably not sufficiently prominently) of the implications of preloading:

Thanks for your help in building better products!

-Eric

Read the whole story
alvinashcraft
49 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Copilot Review To The Rescue

1 Share

For those who are following along with the evolution of my Blog Writing application: after the last revision (allowing follow-up questions to a query) Copilot evaluated my Pull Request and sent me an email that the follow-up was not, in fact, being added to the research stage. This was because the original research was in place and so that step was skipped.

Copilot and I fixed this with the following changes:

Refined follow-ups now reset stale state so the workflow re-enters research instead of reusing old draft/research data.

Updated in ResearchState.cs:80-105
This clears the draft, research findings, review notes, and revision state before the next run.
The blogger routing now correctly sees “no research” after a refinement and sends the next step back to the researcher.

The logic is in BloggerAgent.cs:24-68
The behavior is covered by BloggerAgentRoutingTests.cs:34-58
The researcher’s console output now prints the full query, including the original task and the follow-up refinement, rather than just the bare subtask label.

Updated in ResearcherAgent.cs:69-84
We also added regression tests for both the reset behavior and the routing behavior:

This cycle of using Copilot to generate code and tests, reviewing that code, posting a PR to GitHub, getting back feedback from Copilot and, as needed, making revisions and creating another PR is a powerful development cycle.

For more on this see Copilot Code Review

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Using Session State

1 Share

My demo program is a multi-agent application that accepts a topic BlogAgent, researches that topic (ResearcherAgent) and then drafts a blog post (AuthorAgent). The post is then reviewed by another agent (ReviewerAgent) who either accepts the draft or sends it back to the Author with requested changes.

I wanted to allow the user to refine the search after getting the draft. To do so, I needed short term memory. Here are the changes I made…

I implemented the search refinement flow by updating the research state and wiring it into the research step:

  • In ResearchState.cs, I added:
    • CurrentSubTask to represent the newest follow-up request
    • SearchRefinements to keep the history of prior refinement requests

SearchRefinements is a list<string>. As the ResearchState is created the code checks to see if there are refinements. If so the new refinements are appended to the existing request

       if (SearchRefinements.Count > 0)
        {
            string uniqueRefinements = string.Join(" | ", SearchRefinements
                .Where(r => !string.IsNullOrWhiteSpace(r))
                .Distinct(StringComparer.OrdinalIgnoreCase));

            if (!string.IsNullOrWhiteSpace(uniqueRefinements))
            {
                parts.Add($"Refinement history: {uniqueRefinements}");
            }
        }
  • BuildResearchQuery() to assemble a single, richer research prompt that includes:
    • the original task
    • the active follow-up refinement
    • previous refinement history
    • prior research findings/context
  • StartFollowUp() to preserve the existing draft/research state while resetting the review cycle and recording the previous subtask as a refinement
   public void StartFollowUp(string followUp)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(followUp);

        string trimmed = followUp.Trim();
        if (!string.IsNullOrWhiteSpace(CurrentSubTask) &&
            !string.Equals(CurrentSubTask, trimmed, StringComparison.OrdinalIgnoreCase))
        {
            SearchRefinements.Add(CurrentSubTask.Trim());
        }

        SearchRefinements.Add(trimmed);
        CurrentSubTask = trimmed;
        ReviewNotes = "";
        RevisionNumber = 0;
        NextStep = "";
    }
  • In ResearcherAgent.cs, I changed the research node to use state.BuildResearchQuery() for each research pass, so the agent searches with both the original goal and the refinement history instead of starting from scratch.
    public string BuildResearchQuery()
    {
        var parts = new List<string>();

        if (!string.IsNullOrWhiteSpace(MainTask))
        {
            parts.Add($"Original task: {MainTask.Trim()}");
        }

        if (!string.IsNullOrWhiteSpace(CurrentSubTask))
        {
            parts.Add($"Follow-up refinement: {CurrentSubTask.Trim()}");
        }

        if (SearchRefinements.Count > 0)
        {
            string uniqueRefinements = string.Join(" | ", SearchRefinements
                .Where(r => !string.IsNullOrWhiteSpace(r))
                .Distinct(StringComparer.OrdinalIgnoreCase));

            if (!string.IsNullOrWhiteSpace(uniqueRefinements))
            {
                parts.Add($"Refinement history: {uniqueRefinements}");
            }
        }

        if (ResearchFindings.Count > 0)
        {
            string context = string.Join("\n\n", ResearchFindings
                .Where(f => !string.IsNullOrWhiteSpace(f))
                .Select(f => f.Trim()));

            if (!string.IsNullOrWhiteSpace(context))
            {
                parts.Add($"Prior research context:\n{context}");
            }
        }

        return string.Join("\n\n", parts);
    }
  • In ResearchStateTests.cs, I added coverage confirming that:
    • a follow-up preserves the original task
    • the refinement is included in the generated search query
    • earlier research context is retained
   [Fact]
    public void StartFollowUp_PreservesDraftAndResearchButResetsReviewCycle()
    {
        var state = new ResearchState
        {
            MainTask = "topic",
            ResearchFindings = ["finding"],
            Draft = "draft",
            ReviewNotes = ResearchState.ApprovedMarker,
            RevisionNumber = ResearchState.MaxRevisions,
            NextStep = "END",
        };

        state.StartFollowUp("Add a caching section.");

        Assert.Equal("Add a caching section.", state.CurrentSubTask);
        Assert.Equal("draft", state.Draft);
        Assert.Equal(["finding"], state.ResearchFindings);
        Assert.Empty(state.ReviewNotes);
        Assert.Equal(0, state.RevisionNumber);
        Assert.Empty(state.NextStep);
    }

    [Fact]
    public void BuildResearchQuery_IncludesOriginalTopicAndFollowUpRefinement()
    {
        var state = new ResearchState
        {
            MainTask = "How to build a blog app",
            ResearchFindings = ["Concepts: context, prompts, and workflow.", "Drafting is done after research."],
            CurrentSubTask = "Add a caching section."
        };

        string query = state.BuildResearchQuery();

        Assert.Contains("How to build a blog app", query);
        Assert.Contains("Add a caching section.", query);
        Assert.Contains("Concepts: context, prompts, and workflow.", query);
        Assert.Contains("Drafting is done after research.", query);
    }

Why this enables refining a search

The crucial behavior is that a follow-up no longer discards the earlier work. The generated query now looks like:

  • Original task
  • Follow-up refinement
  • Prior refinement history
  • Earlier research findings

That means the next search is “narrowed and contextualized” rather than replaced, which is the actual refinement behavior you were asking for.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Podcast: MAF Deep Dive

1 Share

Happy to post to my podcast a deep dive into Microsoft Agent Framework with Daniel Costea. Available here or wherever you get your podcasts.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories