diff --git a/.claude/reference/reuse-before-you-build.md b/.claude/reference/reuse-before-you-build.md
new file mode 100644
index 00000000..7b245337
--- /dev/null
+++ b/.claude/reference/reuse-before-you-build.md
@@ -0,0 +1,44 @@
+# Reuse before you build
+
+Before you write a new interface, wrapper, or helper, find out if a package
+already does the job.
+
+**Scope**
+- Applies when you are about to add an abstraction or a utility.
+- Read `Directory.Packages.props` for the packages we already have.
+- Search `Microsoft.Extensions.AI*` and `Microsoft.Agents.AI*` first. They are
+ our base. Then search the rest.
+
+**Prove the overlap**
+- "The framework already does this" is proof only when a scratch probe
+ compiles and runs against the pinned version.
+- Put the probe in the scratchpad. Do not commit it.
+- Name the exact type and member in your report, such as
+ `DelegatingAIFunction`.
+- If the probe fails, say what is missing, then write our own.
+- Reason: in the 2026-08-26 framework audit, 4 of 5 "already covered" claims
+ were wrong.
+
+**A package we do not have yet**
+- Propose the package before you write the code. Do not hand-roll it and
+ mention the package afterwards.
+- Propose one when it replaces more than ~100 lines of our code, or when the
+ job is a known-hard domain: parsing, unicode, time zones, crypto, retry and
+ backoff, globbing, schema validation.
+- Do not install it. Give the owner a short note with:
+ - the package, the exact version, and the licence
+ - what it replaces, in lines and files
+ - how many packages it pulls in, and any native binaries
+ - the date of the last release
+- AgentCore ships as NuGet packages. Every dependency lands on every consumer.
+- MIT, Apache-2.0, BSD, and MS-PL are safe. A copyleft licence (GPL, AGPL,
+ LGPL, MPL) is a stop. Report it and stop.
+- Use a stable version. Prerelease needs the owner's OK.
+- Pin the version in `Directory.Packages.props`.
+
+**Write our own when**
+- No maintained package exists.
+- The library type would leak into our public API.
+- Our need is under ~20 lines and the package is not.
+
+Say which one applies in your report.
diff --git a/.claude/rules/comments.md b/.claude/rules/comments.md
new file mode 100644
index 00000000..110d50c2
--- /dev/null
+++ b/.claude/rules/comments.md
@@ -0,0 +1,74 @@
+---
+paths:
+ - "**/*.cs"
+ - "**/*.sql"
+ - "**/*.csproj"
+ - "**/*.props"
+ - "**/*.targets"
+---
+
+# Comments
+
+Write comments a maintainer needs. Do not write comments that repeat the code.
+
+**Default: no comment.** Code should say *what*. A comment says *why*.
+Before you keep a comment, ask: "Would a good teammate be surprised here
+without it?" If no, delete it.
+
+If you cannot name the reader and the surprise, do not write the comment.
+
+## Do write these
+
+- Why a non-obvious choice was made, when the obvious choice is wrong
+- A workaround, with the cause and a link (bug report, issue, ticket)
+- An invariant or precondition a caller must hold
+- Units, ranges, and limits that the type does not show (`ms`, `0-1`, `UTC`)
+- A pointer to the spec or design section a rule comes from
+- A real safety warning: races, ordering, money, data loss
+- Doc comments on public API, in the language's normal format
+
+## Never write these
+
+- Restating the line: `// increment the counter`, `// return the result`
+- Step narration: `// Step 1: ...`, `// First we ..., then we ...`
+- Changelog or diff talk: `// added for task 5`, `// was 30s before`,
+ `// new`, `// removed the old path`
+- Talking to the reviewer: `// as requested`, `// note: I chose this because`
+- Obvious types or names: `// the user id (string)`
+- Banner art or divider lines: `// ===== HELPERS =====`
+- Commented-out code. Delete it. Git has it.
+- Bare `TODO`. Only keep a TODO with a ticket link.
+
+## Style
+
+- Match the comment density of the file you are editing. Do not add a new
+ style to an old file.
+- One idea per comment. Keep it short.
+- Say it in plain words. No hedging, no apologies, no jokes.
+- Keep the comment next to the code it explains.
+- If a comment explains a confusing block, try to fix the code first. A better
+ name or a small function often beats the comment.
+
+## Cleaning up old comments
+
+Fix comment slop in code you are **already editing**. Do not go hunting.
+
+**Scope**
+- Only inside the function, block, or file you are changing for the task.
+- Never open an unrelated file just to clean comments.
+- Never make a "comment cleanup" commit unless the user asks for one.
+
+**Do this when you see it**
+- Delete any comment that matches the "Never write these" list.
+- Delete a comment that the code no longer matches. A stale comment is worse
+ than no comment.
+- Delete commented-out code.
+- If a comment says *why* but says it badly, keep the *why* and shorten it.
+
+**Do not touch these**
+- Licence and copyright headers.
+- Machine-read comments: `// eslint-disable`, `# type: ignore`, `#pragma`,
+ `// nolint`, ``, codegen markers.
+- Doc comments that a doc tool publishes.
+- A comment you do not understand. It may hold knowledge that is not in the
+ code. Leave it, and say so in your report.
diff --git a/.claude/rules/csharp.md b/.claude/rules/csharp.md
new file mode 100644
index 00000000..7dd0122e
--- /dev/null
+++ b/.claude/rules/csharp.md
@@ -0,0 +1,48 @@
+---
+paths:
+ - "**/*.cs"
+---
+
+# C#
+
+## Style comes from the analyzers
+
+`.editorconfig` raises every IDE style rule to a suggestion, and SonarQube reports the rest. Those
+diagnostics are the style guide. The owner ruled the repo's older habits to be slop, so follow the
+diagnostic, not the code around it.
+
+- Clear every IDE and Sonar diagnostic in the code you touch. Most of them push you to the newer
+ C# form: collection expressions, pattern matching, `is null`, primary constructors.
+- Namespaces are block-scoped. That is the Roslyn default here; do not convert a file to
+ file-scoped.
+- `dotnet format style` is safe except for these fixers. Pass them to `--exclude-diagnostics` and
+ fix by hand:
+ - IDE0010, IDE0072: they write `throw new NotImplementedException()` stubs. Name every member
+ and keep `_ =>` for CS8524.
+ - IDE0130: crashes the workspace. Move or rename the file by hand.
+ - IDE0032: rewrites `ref _field` to `ref Property` and breaks `Interlocked`.
+ - IDE0001, IDE0002: shorten a cref to `ShellKind.X`, which is ambiguous with MAF Tools.Shell's
+ `ShellKind`. Keep the `using ShellKind = ...` alias.
+- IDE0010 and Sonar S3458 conflict on empty cases before `default:`. Give the stacked group its
+ own body, or replace the switch with a `FrozenDictionary` (see `ConversationEventKinds`).
+
+## File size
+
+Keep each `.cs` file under 400 lines.
+
+**Scope**
+- Apply this to files you are **already editing** for the task. Do not go hunting.
+- If a file you must edit is over 400 lines, split it first, then make your change.
+- Skip generated files and `*.schema.json`.
+
+**Rules**
+- One public type per file. Name the file after the type.
+- A private helper type gets its own file when it passes 20 lines.
+- Split by responsibility. Move one cohesive group of members into its own
+ class, in the same namespace and folder.
+- A split that only moves lines does not count. `FooPart2.cs` and a new
+ `partial` are not a split.
+- `partial` is for source generators.
+
+If you cannot find a clean seam, stop and say so in your report. Do not
+split at a random line.
diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md
new file mode 100644
index 00000000..53bf2873
--- /dev/null
+++ b/.claude/rules/tests.md
@@ -0,0 +1,44 @@
+---
+paths:
+ - "tests/**"
+---
+
+# Tests
+
+When you finish adding or changing tests, run the `test-cleanup-audit` skill on the diff before you
+report done.
+
+## Prove each new test fails when the code is wrong
+
+A test that passes whatever the code does proves nothing. Before you report done, do this for
+every test you add or change:
+
+1. Break the production line the test guards: return the wrong value, skip the call, or flip
+ the setting.
+2. Run the test. It must fail.
+3. Put the line back. Check with `git diff` that the file is byte-identical to before.
+
+List each check in your report: the test, the line you broke, and the failure you saw. If a
+test still passes, fix the test before you report done.
+
+## Live-server tests skip without a word
+
+`[PostgresFact]`, `[QdrantFact]`, and `[S3Fact]` skip when their variables are unset. A green
+`dotnet test` then proves nothing about them. Name the variables to run them:
+
+- `AGENTCORE_TEST_POSTGRES`: a connection string. The role must be allowed to create databases
+ and roles.
+- `AGENTCORE_TEST_QDRANT`: `host:port` of the **gRPC** port (6334), not 6333.
+- `AGENTCORE_TEST_S3_ENDPOINT` and `AGENTCORE_TEST_S3_BUCKET`.
+
+A full run against one Qdrant reports about 26 false `RpcException: Timeout expired` failures in
+`KbShapedCorpusFixture`. The 30 s gRPC deadline expires under load. Re-run the failing class with
+`--filter` before you blame your change. Run `pgrep -f testhost.dll` first: a second run against
+the same Qdrant always times out.
+
+## Conventions
+
+- Drive time with `FakeTimeProvider` and `WaitForTimersAsync`. A `Task.Delay` settle loop is
+ flaky.
+- Mark a shared helper that asserts with `[AssertionMethod]` from `AgentCore.TestSupport`.
+ SonarQube S2699 honours it by name.
diff --git a/.claude/rules/voice-components.md b/.claude/rules/voice-components.md
new file mode 100644
index 00000000..96c136b6
--- /dev/null
+++ b/.claude/rules/voice-components.md
@@ -0,0 +1,132 @@
+---
+paths:
+ - "src/AgentCore.AspNetCore/Voice/**"
+ - "tests/AgentCore.AspNetCore.Tests/Voice/**"
+---
+
+# Voice components
+
+```mermaid
+flowchart TB
+ classDef vendor fill:#fde7d9,stroke:#c2410c,color:#1d1d1f
+ classDef port fill:#e0ecff,stroke:#1d4ed8,color:#1d1d1f
+ classDef turn fill:#e6f6ea,stroke:#15803d,color:#1d1d1f
+ classDef speech fill:#f3e8ff,stroke:#7e22ce,color:#1d1d1f
+ classDef engine fill:#fff4c2,stroke:#a16207,color:#1d1d1f
+
+ Caller(["📞 Caller via Telnyx"])
+
+ subgraph Route["Routing"]
+ MapCall["MapCall /v1/{entry}/call"]
+ Boot["AgentCoreBoot ConversationHandler"]
+ end
+
+ subgraph Vendor["Vendors/TelnyxRelay (one per call)"]
+ Conn["TelnyxRelayConnection"]
+ Pump["JsonWebSocketPump read loop"]
+ Reader["TelnyxRelayFrameReader"]
+ In["TelnyxRelayInput (IConversationInputPort)"]
+ Out["TelnyxRelayOutput (IConversationOutputPort)"]
+ Sender["JsonWebSocketSender write loop"]
+ Obs["ConnectionTaskObserver"]
+ end
+
+ subgraph Turn["Turn taking"]
+ Loop["VoiceConversationLoop"]
+ UTH["UserTurnHandler"]
+ Act["VoiceActivity"]
+ end
+
+ subgraph Speech["Speech control"]
+ VS["VoiceSession agent/user state, away timer"]
+ Sched["SpeechScheduler queue + background speeches"]
+ Handle["SpeechHandle one per speech"]
+ Pipe["PipelineReply step loop"]
+ Say["SayReply fixed text"]
+ Fwd["TextForwarding"]
+ Hear["ReplyHearing what caller heard"]
+ Fill["ToolFillerScope → FillerScheduler"]
+ Met["TurnMetrics"]
+ end
+
+ subgraph Engine["AgentCore.Application"]
+ Stream["EngineReplyStream"]
+ Sessions["IConversationSessions"]
+ CS["ConversationSession (IConversationPort)"]
+ end
+
+ Caller <-->|WebSocket JSON| MapCall
+ MapCall --> Boot --> Conn
+ Conn --> Pump & Sender & Loop
+ Pump --> Reader --> In
+ In -->|"ConversationInput: Started / Utterance / Barge"| Loop
+ Loop -->|GetOrOpen / Close| Sessions --> CS
+ Loop -->|interim / final| UTH
+ Loop -->|Barge| Act
+ Loop -->|Barge: StopAsync| Out
+ UTH -->|TryGenerateReply| Act
+ UTH --> VS
+ Act --> Stream
+ Act --> Pipe
+ Act -->|schedule| Sched
+ VS --> Sched
+ VS -->|Say| Say
+ Sched -->|authorize| Handle
+ Pipe --> Handle
+ Pipe -->|read step text| Stream
+ Stream -->|StartTurnAsync / Cut| CS
+ Pipe --> Fwd -->|BeginReply / SpeakAsync / CompleteAsync| Out
+ Say --> Fwd
+ Pipe --> Hear -->|TurnCut| Stream
+ Pipe --> Fill -->|Say| VS
+ Pipe --> Met
+ Out --> Sender -->|JSON frames| Caller
+ Conn --> Obs
+
+ class Conn,Pump,Reader,In,Out,Sender,Obs vendor
+ class MapCall,Boot port
+ class Loop,UTH,Act turn
+ class VS,Sched,Handle,Pipe,Say,Fwd,Hear,Fill,Met speech
+ class Stream,Sessions,CS engine
+```
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant T as Telnyx
+ participant P as Pump + Input
+ participant L as VoiceConversationLoop
+ participant U as UserTurnHandler
+ participant A as VoiceActivity
+ participant S as SpeechScheduler
+ participant R as PipelineReply
+ participant E as EngineReplyStream → ConversationSession
+ participant O as Output + Sender
+
+ T->>P: setup frame
+ P->>L: Started(conversationId)
+ L->>L: GetOrOpen session, make VoiceActivity + UserTurnHandler
+ T->>P: interim transcript
+ P->>L: Utterance(IsFinal=false)
+ L->>U: OnInterimTranscript → user Speaking
+ T->>P: final transcript
+ P->>L: Utterance(IsFinal=true)
+ L->>U: OnFinalTranscript
+ U->>A: InterruptByFinalTranscript (cut old speech)
+ U->>A: TryGenerateReply(text)
+ A->>E: Start engine turn
+ A->>S: ScheduleSpeech(handle)
+ S->>R: authorize speech
+ R->>R: wait for user silence
+ loop each model step
+ E-->>R: step text
+ R->>O: BeginReply / SpeakAsync / CompleteAsync
+ O->>T: speak frames
+ opt step calls a tool
+ R->>R: WaitForToolsAsync (filler may Say)
+ R->>S: re-schedule (force) for next step
+ end
+ end
+ R->>S: speech done → agent Listening, away timer armed
+ Note over T,O: Barge-in: Telnyx sends Barge → Loop calls Output.StopAsync and VoiceActivity.InterruptByAudioActivity → ReplyHearing cuts the engine turn to what was heard
+```
diff --git a/.claude/rules/voice.md b/.claude/rules/voice.md
new file mode 100644
index 00000000..934ad23a
--- /dev/null
+++ b/.claude/rules/voice.md
@@ -0,0 +1,13 @@
+---
+paths:
+ - "src/AgentCore.AspNetCore/Voice/**"
+ - "tests/AgentCore.AspNetCore.Tests/Voice/**"
+---
+
+# Voice
+
+Read `.claude/rules/voice-components.md` before you change this folder. It maps the parts of one phone
+call: the three loops (read, listen, write), who decides *when* the agent talks, and where the
+engine (`IConversationPort`) decides *what* it says.
+
+When your change adds, removes, or rewires a component, update the diagram in the same change.
diff --git a/.gitignore b/.gitignore
index b113dca4..fb3e616c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -502,7 +502,6 @@ eval-report.html
# Harness-created agent worktrees. Never commit these.
.claude/worktrees/
-CLAUDE.md
docs/
.superpowers/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..ffd55276
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,26 @@
+AgentCore is a .NET 10 library. It ships as NuGet packages, so every dependency lands on every consumer.
+
+Rules for C#, comments, tests, and the Voice folder live in `.claude/rules/`. Each one loads when you open a file it covers.
+
+## Build and test
+
+- `dotnet build` fails on any warning. Fix the cause. Ask the owner before you add a `NoWarn`, a `#pragma warning disable`, or an `.editorconfig` severity change.
+- `dotnet test` skips the Postgres, Qdrant, and S3 tests without a word when their `AGENTCORE_TEST_*` variables are unset. A green run does not cover them. See `.claude/rules/tests.md`.
+
+## Before you add an interface, wrapper, helper, or package
+
+Read `.claude/reference/reuse-before-you-build.md` first.
+
+## Agent skills
+
+### Issue tracker
+
+Issues and specs live as GitHub issues in MatthewHsu1/AgentCore, used through the `gh` CLI. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+The five default labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context: one `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2a3cb6a7..a5159055 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -44,6 +44,7 @@
+
diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0
new file mode 100644
index 00000000..261eeb9e
--- /dev/null
+++ b/LICENSE-APACHE-2.0
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..2663dc34
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,6 @@
+AgentCore
+Copyright 2026 Matthew Hsu
+
+This product includes software derived from LiveKit Agents
+(https://github.com/livekit/agents), commit d8405f132e1bd960f298190c18daf81ffc1faf45.
+Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0.
diff --git a/src/AgentCore.Application/AgentCore.Application.csproj b/src/AgentCore.Application/AgentCore.Application.csproj
index e838bfc5..3e74153d 100644
--- a/src/AgentCore.Application/AgentCore.Application.csproj
+++ b/src/AgentCore.Application/AgentCore.Application.csproj
@@ -18,6 +18,7 @@
+
@@ -39,6 +40,7 @@
+
diff --git a/src/AgentCore.Application/Audit/QueuedAuditSink.cs b/src/AgentCore.Application/Audit/QueuedAuditSink.cs
index 9590cbcd..49a692d9 100644
--- a/src/AgentCore.Application/Audit/QueuedAuditSink.cs
+++ b/src/AgentCore.Application/Audit/QueuedAuditSink.cs
@@ -1,9 +1,12 @@
+using System.Data.Common;
using System.Threading.Channels;
using AgentCore.Application.Diagnostics;
using AgentCore.Application.Ports;
using AgentCore.Domain.Audit;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using Polly;
+using Polly.Retry;
namespace AgentCore.Application.Audit
{
@@ -18,10 +21,22 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa
/// The most events the writer hands the store in one conversation.
public const int DefaultBatchSize = 100;
+ /// How many times a batch the store refused with a transient fault is handed to it again.
+ private const int MaxRetryAttempts = 3;
+
+ /// The median wait before the first retry. Each later wait doubles it, with jitter.
+ private static readonly TimeSpan FirstRetryDelay = TimeSpan.FromMilliseconds(200);
+
+ /// The longest any one wait between retries can be.
+ private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(2);
+
/// How long waits for the drain before it gives up.
private static readonly TimeSpan DisposeTimeout = TimeSpan.FromSeconds(5);
+
private readonly ILogger _logger;
+ private readonly ResiliencePipeline _retry;
+
private readonly Channel _queue;
private readonly CancellationTokenSource _stopping = new();
@@ -50,6 +65,9 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa
///
/// The most events one call to the store carries, or .
///
+ ///
+ /// The clock the waits between retries run on, or for .
+ ///
/// is .
///
/// or is less than one.
@@ -58,7 +76,8 @@ public QueuedAuditSink(
IAuditSinkPort inner,
ILogger? logger = null,
int capacity = DefaultCapacity,
- int batchSize = DefaultBatchSize)
+ int batchSize = DefaultBatchSize,
+ TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
@@ -70,6 +89,8 @@ public QueuedAuditSink(
_batchSize = batchSize;
+ _retry = RetryPipeline(timeProvider ?? TimeProvider.System);
+
_queue = Channel.CreateBounded(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait,
@@ -118,20 +139,13 @@ public async ValueTask FlushAsync(CancellationToken cancellationToken = default)
/// A task that completes when the drain is done.
public async ValueTask DisposeAsync()
{
- // A container captures this once per service type it is registered under, and it answers to
- // two. Without this guard the second close cancels a CancellationTokenSource the first one
- // already disposed.
if (Interlocked.Exchange(ref _closed, 1) != 0)
{
return;
}
- // Completing the writer is what ends the drain loop, and it is also what makes every later
- // TryWrite return false. The queue keeps whatever is already in it, so the drain is honest.
_ = _queue.Writer.TryComplete();
- // Bounded on purpose. A host that stops is never held by a store that will not answer, and a
- // store that does answer gets the whole window to write what it already accepted.
if (!await DrainedAsync().ConfigureAwait(false))
{
await _stopping.CancelAsync().ConfigureAwait(false);
@@ -190,14 +204,35 @@ private async Task WriteAsync()
}
}
- /// Hands one batch to the store, and reports whatever it refuses.
+ /// Builds the retry a refused batch goes through before it is dropped.
+ /// The clock the waits between attempts run on.
+ /// The pipeline.
+ private static ResiliencePipeline RetryPipeline(TimeProvider timeProvider)
+ {
+ return new ResiliencePipelineBuilder { TimeProvider = timeProvider }
+ .AddRetry(new RetryStrategyOptions
+ {
+ ShouldHandle = new PredicateBuilder().Handle(exception => exception.IsTransient),
+ MaxRetryAttempts = MaxRetryAttempts,
+ BackoffType = DelayBackoffType.Exponential,
+ UseJitter = true,
+ Delay = FirstRetryDelay,
+ MaxDelay = MaxRetryDelay,
+ })
+ .Build();
+ }
+
+ /// Hands one batch to the store, retries a transient fault, and reports a batch it drops.
/// The events to write, in the order they were accepted.
/// A task that always completes, and never faults.
private async Task AppendBatchAsync(List batch)
{
try
{
- await Store.AppendManyAsync([.. batch], _stopping.Token).ConfigureAwait(false);
+ await _retry.ExecuteAsync(
+ static (state, cancellationToken) => state.Store.AppendManyAsync(state.Events, cancellationToken),
+ (Store, Events: (IReadOnlyList)[.. batch]),
+ _stopping.Token).ConfigureAwait(false);
}
#pragma warning disable CA1031 // The store is a record of the conversation and never a part of it.
catch (Exception exception)
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
index 3dd7c33a..b381ce9c 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
@@ -11,6 +11,7 @@ namespace AgentCore.Application.Configuration.Compilation
///
/// Turns one declaration into the function the outer agent conversations.
///
+#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0.
internal static class AgentDelegationTool
{
/// The argument name AsAIFunction() generates when the document declares no schema.
@@ -19,20 +20,20 @@ internal static class AgentDelegationTool
/// Builds the function that runs one declared agent.
/// The kind: agent declaration.
/// The already compiled inner agent.
+ ///
+ /// Every background provider the document compiled, so the delegation's fresh session releases its
+ /// background children when the tool returns.
+ ///
/// The function the outer agent advertises.
- internal static AIFunction Create(ToolConfiguration tool, AIAgent inner)
+ internal static AIFunction Create(ToolConfiguration tool, AIAgent inner, IReadOnlyList backgroundProviders)
{
- // The document names the function and describes it, because the calling model reads both to
- // decide when to delegate. Without a description it falls back to the inner agent's own.
- // AsAIFunction is the schema donor only — the wrapper below runs the agent directly, so
- // its name, description and parameters stay exactly what the model was built against.
AIFunction function = inner.AsAIFunction(new AIFunctionFactoryOptions
{
Name = tool.Id,
Description = tool.Description ?? inner.Description,
});
- return new DeclaredSchemaFunction(function, tool.Parameters, tool.Id, inner);
+ return new DeclaredSchemaFunction(function, tool.Parameters, tool.Id, inner, backgroundProviders);
}
///
@@ -51,13 +52,22 @@ private sealed class DeclaredSchemaFunction : DelegatingAIFunction
private readonly AIAgent _inner;
- internal DeclaredSchemaFunction(AIFunction inner, JsonNode? parameters, string toolId, AIAgent innerAgent)
+ private readonly IReadOnlyList _backgroundProviders;
+
+ internal DeclaredSchemaFunction(
+ AIFunction inner,
+ JsonNode? parameters,
+ string toolId,
+ AIAgent innerAgent,
+ IReadOnlyList backgroundProviders)
: base(inner)
{
ArgumentNullException.ThrowIfNull(toolId);
+ ArgumentNullException.ThrowIfNull(backgroundProviders);
_toolId = toolId;
_inner = innerAgent ?? throw new ArgumentNullException(nameof(innerAgent));
+ _backgroundProviders = backgroundProviders;
if (parameters is null)
{
@@ -84,24 +94,19 @@ public override JsonElement JsonSchema
TurnInvocation? parent = TurnInvocation.FiledIn(arguments);
- // Schemaless the donor advertises one query string; declared, the payload the
- // document shaped. Either way the nested agent reads words, not arguments.
string query = _argument is null
&& arguments.TryGetValue(DefaultArgument, out object? bare)
&& BareText(bare) is { } text
? text
: ToolArgumentJson.ToJsonObject(arguments).ToJsonString();
- // Decided here where the parent turn is in hand: the nested run neither latches
- // the holder (K42) nor records, and only the run under this tool is offered its
- // tools. Without a turn the nested run is bare, as before.
TurnInvocation? nested = parent is not null
? parent with { Clarifications = null, Nested = true }
: null;
IReadOnlyList? offered = parent?.ToolsFor == _toolId ? parent?.Tools : null;
- return DelegatedAgentRun.RunAsync(_inner, query, nested, offered, cancellationToken);
+ return DelegatedAgentRun.RunAsync(_inner, query, nested, offered, _backgroundProviders, cancellationToken);
}
private static string? BareText(object? value)
@@ -128,4 +133,5 @@ public override JsonElement JsonSchema
}
}
+#pragma warning restore MAAI001
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs
index c93fe5dd..3de0e9cf 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace AgentCore.Application.Configuration.Compilation
{
+#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0.
internal static class AgentToolCompiler
{
public static List? Build(
@@ -16,7 +17,8 @@ internal static class AgentToolCompiler
Dictionary declared,
AgentCompilationContext context,
string pointer,
- Func resolveAgent)
+ Func resolveAgent,
+ IReadOnlyList backgroundProviders)
{
if (item.Tools.Count == 0)
{
@@ -30,7 +32,7 @@ internal static class AgentToolCompiler
string toolPointer = ConfigurationError.AppendPointer(
ConfigurationError.AppendPointer(pointer, "tools"), index);
- if (Resolve(id, declared, context.Tools, resolveAgent, toolPointer) is { } tool)
+ if (Resolve(id, declared, context.Tools, resolveAgent, backgroundProviders, toolPointer) is { } tool)
{
Add(tools, tool, item.Id, id, model, context);
}
@@ -45,6 +47,7 @@ internal static class AgentToolCompiler
Dictionary declared,
ToolRegistry? registry,
Func resolveAgent,
+ IReadOnlyList backgroundProviders,
string pointer)
{
if (!declared.TryGetValue(id, out ToolConfiguration? tool))
@@ -54,9 +57,7 @@ internal static class AgentToolCompiler
if (tool.Kind == ToolKind.Agent)
{
- // A kind: agent tool needs no tool factory. Section 7 says section 8 adds no port,
- // and this kind adds none either: the inner agent is already in the document.
- return AgentDelegationTool.Create(tool, ResolveInner(tool, resolveAgent, pointer));
+ return AgentDelegationTool.Create(tool, ResolveInner(tool, resolveAgent, pointer), backgroundProviders);
}
return Declared(id, registry, pointer);
@@ -67,7 +68,6 @@ internal static class AgentToolCompiler
{
if (registry is null)
{
- // No factory, so nothing could have been built anyway.
return null;
}
@@ -88,10 +88,6 @@ _ when registry.Contains(id) => registry.Resolve(id),
}
/// Adds one tool, unless it is a hosted marker its agent's model cannot run.
- ///
- /// Tested by type and not by the builtin name, so a hosted marker a host supplies through
- /// its own IToolSource is covered by the same rule.
- ///
private static void Add(
List tools,
AITool tool,
@@ -105,6 +101,7 @@ private static void Add(
if (context.ChatClients.ResolveHostedTool(tool, model) is not { } resolved)
{
ILogger logger = context.Loggers?.CreateLogger(typeof(AgentToolCompiler)) ?? NullLogger.Instance;
+
string modelDescription = model is { Ref.Length: > 0 } ? $"the model '{model.Ref}'" : "this agent's default model";
Log.HostedToolDropped(logger, agentId, toolId, tool.GetType().Name, modelDescription);
@@ -129,4 +126,5 @@ private static AIAgent ResolveInner(ToolConfiguration tool, Func
- /// One row of the section 8.2 compile table. The row owns everything the compiler decides per
- /// shape, so adding a shape is one new row and nothing else.
+ /// Turns one entry into the agent a turn runs.
///
internal abstract class CompileTableRow
{
@@ -14,7 +13,6 @@ internal abstract class CompileTableRow
internal abstract CompiledAgentShape Shape { get; }
/// Gets whether the row answers its runs out of store 1 on its own session.
- /// Rows 1 and 2 ride the session. A graph run rides the request messages.
internal virtual bool SessionCarriesHistory => false;
/// Builds the entry agent of one entry, and the stage table when the row has one.
@@ -24,6 +22,11 @@ internal abstract class CompileTableRow
/// The JSON Pointer to the entry, /entries/<name>.
/// The compiled agents.items entries, keyed by id.
/// The seams the document names.
+ ///
+ /// when a conversation on this entry keeps one MAF session across turns
+ /// (!SessionCarriesHistory && HarnessStateKeys.Count > 0, the same test
+ /// runs). Only the graph rows read it.
+ ///
/// The agent a turn runs, and the agent id each policy.stages entry names.
/// The entry does not compile through this row.
internal abstract EntryBuild BuildEntry(
@@ -32,7 +35,8 @@ internal abstract EntryBuild BuildEntry(
EntryConfiguration entry,
string entryPointer,
Dictionary agents,
- AgentCompilationContext context);
+ AgentCompilationContext context,
+ bool reusesGraphSession);
/// Names the agents whose reply the caller actually hears.
/// The loaded document.
diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
index a6638381..1e5dd8b8 100644
--- a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
@@ -80,7 +80,7 @@ internal CompiledAgent(CompiledAgentBuilder builder)
///
/// Gets the agents whose reply the caller hears, or for all of them.
///
- internal IReadOnlySet? SpokenBy => _layers.SpokenBy;
+ internal IReadOnlySet? OutputAgents => _layers.OutputAgents;
///
/// Gets store 1 of every conversation this agent answers.
diff --git a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
index 54b04bc5..e28bfac0 100644
--- a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
@@ -10,7 +10,7 @@
namespace AgentCore.Application.Configuration.Compilation
{
///
- /// The compile table of section 8.2. It is a table, not a heuristic.
+ /// Compiles a loaded document into one agent per entry.
///
public static class ConfigurationCompiler
{
@@ -104,13 +104,15 @@ public static IReadOnlyDictionary CompileAll(
built[key] = shared;
}
- EntryBuild build = row.BuildEntry(configuration, entryName, entry, entryPointer, shared.Agents, context);
+ bool reusesGraphSession = !row.SessionCarriesHistory && shared.HarnessStateKeys.Count > 0;
+ EntryBuild build = row.BuildEntry(configuration, entryName, entry, entryPointer, shared.Agents, context, reusesGraphSession);
TurnLayers layers = new(
entry.FallbackReply ?? configuration.FallbackReply,
entry.RefusalReply ?? configuration.RefusalReply,
context.Moderation,
- row.SpokenAuthors(configuration, entry));
+ row.SpokenAuthors(configuration, entry),
+ document.History);
compiled[entryName] = new CompiledAgentBuilder
{
@@ -141,6 +143,7 @@ private static CompiledAgentSet BuildAgents(
Dictionary agents = new(StringComparer.Ordinal);
HashSet harnessStateKeys = new(StringComparer.Ordinal);
List backgroundProviders = [];
+
if (configuration.Agents is not { } section)
{
return new CompiledAgentSet(agents, harnessStateKeys, backgroundProviders);
@@ -208,13 +211,14 @@ private static CompiledAgentSet BuildAgents(
harnessStateKeys.UnionWith(AgentHarnessProviders.StateKeysOf(providers));
List? compiledTools = AgentToolCompiler.Build(
- item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve);
+ item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve, backgroundProviders);
harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools));
ChatClientAgent built = new(
WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model)),
new ChatClientAgentOptions
{
+ Id = GraphExecutorId(item.Id),
Name = item.Id,
Description = item.Description,
ChatOptions = new ChatOptions
@@ -241,6 +245,14 @@ private static CompiledAgentSet BuildAgents(
}
#pragma warning restore MAAI001
+ ///
+ /// Encodes an agents.items id into MAF's executor-id alphabet.
+ ///
+ private static string GraphExecutorId(string id)
+ {
+ return Convert.ToHexStringLower(System.Text.Encoding.UTF8.GetBytes(id));
+ }
+
/// Puts the auditing function-invocation loop into the pipeline of one agent.
private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatClient model)
{
@@ -251,6 +263,28 @@ private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatC
.Build());
}
+ ///
+ /// Whether the named agents.items entry declares background:.
+ ///
+ internal static bool AgentDeclaresBackground(AgentCoreConfiguration configuration, string agentId)
+ {
+ return configuration.Agents?.Items.Any(item => string.Equals(item.Id, agentId, StringComparison.Ordinal) && item.Background.Count > 0) == true;
+ }
+
+ ///
+ /// Builds check refusal for an agent that declares background: and is also used as a
+ /// graph node. Both graph rows raise this the same way, so the message lives once.
+ ///
+ internal static ConfigurationLoadException FailBackgroundInGraph(string pointer, string agentId, string entryName)
+ {
+ return Fail(
+ pointer,
+ $"the agent '{agentId}' declares background: and is also used as a node in the graph of "
+ + $"entry '{entryName}'. MAF has no way to release a graph node's session, so a "
+ + "background child started inside a node would keep running after the conversation "
+ + "ends. Remove background: from this agent, or stop using it as a graph node.");
+ }
+
internal static ConfigurationLoadException Fail(string pointer, string message)
{
return new(new ConfigurationError
diff --git a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
index 95c72655..3af4aad6 100644
--- a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
@@ -8,7 +8,7 @@
namespace AgentCore.Application.Configuration.Compilation
{
///
- /// Row 4: the entry holds graph: with nodes: and edges:. It builds a
+ /// Holds graph: with nodes: and edges:. It builds a
/// WorkflowBuilder, binds the agents as executors, then AsAIAgent().
///
internal sealed class ExplicitGraphRow : CompileTableRow
@@ -23,15 +23,19 @@ internal override EntryBuild BuildEntry(
EntryConfiguration entry,
string entryPointer,
Dictionary agents,
- AgentCompilationContext context)
+ AgentCompilationContext context,
+ bool reusesGraphSession)
{
GraphConfiguration graph = entry.Graph!;
string graphPointer = ConfigurationError.AppendPointer(entryPointer, "graph");
- (Dictionary? nodes, string? start, List? outputs) = BindNodes(graph, ConfigurationError.AppendPointer(graphPointer, "nodes"), agents);
+
+ (Dictionary? nodes, string? start, List? outputs) = BindNodes(
+ configuration, entryName, graph, ConfigurationError.AppendPointer(graphPointer, "nodes"), agents);
ExecutorBinding stateEntry = ExecutorBindingExtensions.BindExecutor(new GraphStateEntry());
WorkflowBuilder builder = new(stateEntry);
+
builder = builder.AddEdge(stateEntry, nodes[start]);
(WorkflowBuilder? built, bool guarded) = AddEdges(builder, graph, ConfigurationError.AppendPointer(graphPointer, "edges"), nodes, context);
@@ -40,11 +44,12 @@ internal override EntryBuild BuildEntry(
built = built.WithOutputFrom([.. outputs]);
}
- AIAgent compiled = built.WithName(entryName)
- .Build()
- .AsAIAgent(name: entryName);
+ AIAgent compiled = built
+ .WithName(entryName)
+ .Build()
+ .AsAIAgent(name: entryName);
- AIAgent withOutputCheck = new RequireOutputAgent(compiled, entryName);
+ AIAgent withOutputCheck = new RequireOutputAgent(new GraphFaultAgent(compiled, drain: reusesGraphSession), entryName);
return new EntryBuild(
guarded ? new GraphStateAgent(withOutputCheck, entryName) : withOutputCheck,
@@ -53,6 +58,8 @@ internal override EntryBuild BuildEntry(
/// Binds every node to its agent, and finds the one start node.
private static (Dictionary Nodes, string Start, List Outputs) BindNodes(
+ AgentCoreConfiguration configuration,
+ string entryName,
GraphConfiguration graph,
string nodesPointer,
Dictionary agents)
@@ -64,7 +71,7 @@ private static (Dictionary Nodes, string Start, List Nodes, string Start, List agents,
@@ -105,8 +114,11 @@ private static AIAgentBinding BindNode(
$"the node id '{node.Id}' is declared twice.");
}
- // The measured shape: an agent binds as an executor, and the host emits update events so
- // the wrapper can stream. Section 8.6.
+ if (ConfigurationCompiler.AgentDeclaresBackground(configuration, agentId))
+ {
+ throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(pointer, "agent"), agentId, entryName);
+ }
+
return new AIAgentBinding(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = true });
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
index dea68cf7..b4d645c3 100644
--- a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
@@ -1,12 +1,13 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Runtime;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
namespace AgentCore.Application.Configuration.Compilation
{
///
- /// Row 3: the entry holds graph: with pattern:. It builds
+ /// Entry holds graph: with pattern:. It builds
/// AgentWorkflowBuilder.BuildSequential, BuildConcurrent,
/// CreateHandoffBuilderWith, or CreateGroupChatBuilderWith.
///
@@ -22,7 +23,8 @@ internal override EntryBuild BuildEntry(
EntryConfiguration entry,
string entryPointer,
Dictionary agents,
- AgentCompilationContext context)
+ AgentCompilationContext context,
+ bool reusesGraphSession)
{
GraphConfiguration graph = entry.Graph!;
string graphPointer = ConfigurationError.AppendPointer(entryPointer, "graph");
@@ -39,6 +41,11 @@ internal override EntryBuild BuildEntry(
$"the graph names the agent '{id}', which agents.items does not declare.");
}
+ if (ConfigurationCompiler.AgentDeclaresBackground(configuration, id))
+ {
+ throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(agentsPointer, index), id, entryName);
+ }
+
participants.Add(agent);
}
@@ -56,7 +63,7 @@ internal override EntryBuild BuildEntry(
_ => throw new ArgumentOutOfRangeException(nameof(entry), graph.Pattern, "The graph pattern vocabulary is closed, and this value is not in it."),
};
- return new EntryBuild(workflow.AsAIAgent(name: entryName), NoStages());
+ return new EntryBuild(new GraphFaultAgent(workflow.AsAIAgent(name: entryName), drain: reusesGraphSession), NoStages());
}
///
diff --git a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs
index 6b02680d..68cdfb2e 100644
--- a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs
@@ -5,7 +5,7 @@
namespace AgentCore.Application.Configuration.Compilation
{
///
- /// Row 2: the entry holds policy:. The machine picks a stage each turn, the stage names
+ /// Entry holds policy:. The machine picks a stage each turn, the stage names
/// one agent, and that agent's run answers the caller. Runtime is Stateless.
///
internal sealed class PolicyRow : CompileTableRow
@@ -25,7 +25,8 @@ internal override EntryBuild BuildEntry(
EntryConfiguration entry,
string entryPointer,
Dictionary agents,
- AgentCompilationContext context)
+ AgentCompilationContext context,
+ bool reusesGraphSession)
{
PolicyConfiguration policy = entry.Policy!;
string policyPointer = ConfigurationError.AppendPointer(entryPointer, "policy");
@@ -37,6 +38,7 @@ internal override EntryBuild BuildEntry(
StageConfiguration stage = policy.Stages[index];
string stagePointer = ConfigurationError.AppendPointer(
ConfigurationError.AppendPointer(policyPointer, "stages"), index);
+
if (stage.Agent is not { } agentId)
{
stages[stage.Id] = NoAgentId;
diff --git a/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs b/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs
index 3fdcf3a2..bccfc57b 100644
--- a/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs
@@ -5,7 +5,7 @@
namespace AgentCore.Application.Configuration.Compilation
{
///
- /// Row 1: the entry holds agent:. The named agents.items entry is the
+ /// Entry holds agent:. The named agents.items entry is the
/// entry, and its run's own last message is the reply.
///
internal sealed class SingleAgentRow : CompileTableRow
@@ -22,7 +22,8 @@ internal override EntryBuild BuildEntry(
EntryConfiguration entry,
string entryPointer,
Dictionary agents,
- AgentCompilationContext context)
+ AgentCompilationContext context,
+ bool reusesGraphSession)
{
string agentPointer = ConfigurationError.AppendPointer(entryPointer, "agent");
diff --git a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
index 1cc232b2..050ed800 100644
--- a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
@@ -1,30 +1,39 @@
using AgentCore.Application.Evaluation;
using AgentCore.Application.Runtime;
+using AgentCore.Application.Transcript;
using Microsoft.Agents.AI;
namespace AgentCore.Application.Configuration.Compilation
{
- /// The two turn-disposition layers one entry puts on every agent a turn runs.
+ /// The turn layers one entry puts on every agent a turn runs: the two disposition layers, and the seal outside them.
/// The resolved line the caller hears when a turn fails.
/// The resolved line the caller hears when the agent refuses to answer.
/// The endpoint seam, or to moderate nothing.
- /// The agents whose reply the caller hears, or for all.
+ /// The agents whose reply the caller hears, or for all.
+ /// Store 1, which the seal commits every turn to.
internal sealed record TurnLayers(
string FallbackReply,
string RefusalReply,
PromptModerator? Moderation,
- IReadOnlySet? SpokenBy)
+ IReadOnlySet? OutputAgents,
+ AgentCoreChatHistoryProvider History)
{
- /// Puts the layers on one agent.
+ /// Puts the layers on one agent, outermost first: the seal, then moderation, then the fallback.
/// The compiled agent of one entry, or of one policy: stage.
/// The agent the turn loop runs.
public AIAgent Apply(AIAgent agent)
{
- AIAgent layered = new FallbackAgent(agent, FallbackReply, SpokenBy);
+ AIAgentBuilder builder = new AIAgentBuilder(agent)
+ .Use(inner => new ConversationTurnAgent(inner, History));
- return Moderation is null
- ? layered
- : new ModerationAgent(layered, Moderation, RefusalReply, ModerationAgent.DefaultTimeout);
+ if (Moderation is not null)
+ {
+ builder = builder.Use(inner => new ModerationAgent(inner, Moderation, RefusalReply, ModerationAgent.DefaultTimeout));
+ }
+
+ return builder
+ .Use(inner => new FallbackAgent(inner, FallbackReply, OutputAgents))
+ .Build();
}
}
}
diff --git a/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs b/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs
index 61dc124f..5bc30955 100644
--- a/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs
+++ b/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs
@@ -39,5 +39,12 @@ public enum ConfigurationCheck
/// still carries a pointer into the document.
///
ValueRange = 9,
+
+ ///
+ /// Check 10: an agent used as a graph node may not declare background:. MAF 1.21.0 gives a
+ /// workflow node's session no release hook, so a background child started inside a node would
+ /// outlive the conversation. This table row has no entry in section 8.5.
+ ///
+ GraphNodeBackground = 10,
}
}
diff --git a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
index e681189b..93b6e8cf 100644
--- a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
+++ b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
@@ -1,3 +1,7 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
namespace AgentCore.Application.Configuration.Schema
{
///
@@ -16,5 +20,18 @@ public sealed record ConversationProviderConfiguration
/// Gets the largest inbound frame the socket accepts, in bytes, or null for the adapter's default.
public int? MaxFrameBytes { get; init; }
+
+ ///
+ /// Gets the caller-away prompt a voice adapter opens: { timeoutSeconds, say }. Voice conversations only.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public JsonElement UserAway { get; init; }
+
+ ///
+ /// Gets the filler each tool id opens while its call is running, keyed by tool id. Voice
+ /// conversations only. A tool id this does not name opens no filler.
+ ///
+ public IReadOnlyDictionary Filler { get; init; } =
+ ReadOnlyDictionary.Empty;
}
}
diff --git a/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs
new file mode 100644
index 00000000..a4b7e840
--- /dev/null
+++ b/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs
@@ -0,0 +1,20 @@
+namespace AgentCore.Application.Configuration.Schema
+{
+ ///
+ /// What one tool id's filler says while its step runs, and how often. Voice conversations only.
+ ///
+ public sealed record VoiceFillerConfiguration
+ {
+ /// Gets what the agent says while the tool call runs.
+ public required string Say { get; init; }
+
+ /// Gets how long the session must stay continuously idle before the first fire.
+ public required double DelaySeconds { get; init; }
+
+ /// Gets the cooldown after a fire before the dwell restarts, or null to fire at most once.
+ public double? IntervalSeconds { get; init; }
+
+ /// Gets the most fires across the tool call's lifetime, or null for no limit.
+ public int? MaxSteps { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
index 0cc2dcf4..7184c264 100644
--- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
+++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
@@ -1365,6 +1365,62 @@
"minimum": 1,
"maximum": 2147483647,
"description": "The largest inbound frame the socket accepts. The ceiling is int.MaxValue, which is the largest buffer length the socket can be given."
+ },
+ "userAway": {
+ "type": ["object", "null"],
+ "description": "When the caller counts as away, and what the agent says once they do. Voice conversations only. Omit the key to keep the shipped default (15 s, \"Are you still there?\"); write null to turn the away prompt off.",
+ "required": ["timeoutSeconds", "say"],
+ "additionalProperties": false,
+ "properties": {
+ "timeoutSeconds": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "How long both sides must stay idle before the caller is marked away."
+ },
+ "say": {
+ "type": "string",
+ "minLength": 1,
+ "description": "What the agent says once the caller is marked away."
+ }
+ }
+ },
+ "filler": {
+ "type": "object",
+ "description": "The filler each tool id opens while its call is running, keyed by tool id. Voice conversations only. A tool id this block does not name gets no filler.",
+ "propertyNames": {
+ "minLength": 1
+ },
+ "additionalProperties": {
+ "$ref": "#/$defs/voiceFiller"
+ }
+ }
+ }
+ },
+ "voiceFiller": {
+ "type": "object",
+ "description": "What one tool id's filler says while its step runs, and how often.",
+ "required": ["say", "delaySeconds"],
+ "additionalProperties": false,
+ "properties": {
+ "say": {
+ "type": "string",
+ "minLength": 1,
+ "description": "What the agent says while the tool call runs."
+ },
+ "delaySeconds": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "How long the session must stay continuously idle before the first fire."
+ },
+ "intervalSeconds": {
+ "type": ["number", "null"],
+ "exclusiveMinimum": 0,
+ "description": "The cooldown after a fire before the dwell restarts. Omitted or null fires at most once."
+ },
+ "maxSteps": {
+ "type": ["integer", "null"],
+ "minimum": 1,
+ "description": "The most fires across the tool call's lifetime. Omitted or null means no limit."
}
}
},
diff --git a/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs b/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs
index c9f76c12..6fb7989d 100644
--- a/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs
+++ b/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs
@@ -4,14 +4,20 @@
namespace AgentCore.Application.Configuration.Validation
{
///
- /// Checks 2 to 8 of section 8.5, over one bound document. Each check lives in its own
- /// *Check class in this folder; this type only orders them and shapes the result.
+ /// Finds the mistakes in a loaded document that the JSON Schema cannot catch, such as a name
+ /// that points to nothing or a stage that no path reaches.
///
public static class ConfigurationValidator
{
- /// Runs checks 2 to 8 and returns everything they find.
- /// The bound document.
- /// Every error and every partial-coverage warning.
+ ///
+ /// Runs , then checks each tool reference against the tools the
+ /// document declares. It never throws on a bad document; does.
+ ///
+ /// The loaded document.
+ ///
+ /// The errors, and the warnings. A warning does not stop the load. Some say a check had too
+ /// many cases to try them all, so it tried a random sample.
+ ///
public static ConfigurationValidationResult Evaluate(AgentCoreConfiguration configuration)
{
ConfigurationValidationResult structural = EvaluateStructure(configuration);
@@ -30,21 +36,21 @@ public static ConfigurationValidationResult Evaluate(AgentCoreConfiguration conf
};
}
- /// Runs checks 2 to 8 and throws when any of them fails.
- /// The bound document.
- /// The result, so a caller can read the partial-coverage warnings of check 5.
- /// The document fails one or more checks.
+ /// Runs and throws when it finds an error.
+ /// The loaded document.
+ /// The result, so the caller can read the warnings.
+ /// The document has one or more errors.
public static ConfigurationValidationResult Validate(AgentCoreConfiguration configuration)
{
return ThrowOnErrors(Evaluate(configuration));
}
///
- /// Runs every check of section 8.5 except tool-reference resolution, and returns everything they
- /// find.
+ /// Runs every check that needs only the document, and returns what they find. It skips tool
+ /// references, because MCP tools are not known until the servers answer.
///
- /// The bound document.
- /// Every error and every partial-coverage warning.
+ /// The loaded document.
+ /// The errors and the warnings, as describes them.
public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -63,6 +69,7 @@ public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfigura
ExitExclusivityCheck.Run(configuration, errors, warnings);
ReachabilityCheck.Run(configuration, errors);
GraphWellFormednessCheck.Run(configuration, errors);
+ GraphNodeBackgroundCheck.Run(configuration, errors);
DelegationCycleCheck.Run(configuration, errors);
McpCheck.ServerIds(configuration, errors);
McpCheck.SecretPlacement(configuration, errors);
@@ -76,23 +83,20 @@ public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfigura
};
}
- ///
- /// Runs every check of section 8.5 except tool-reference resolution, and throws when any of them
- /// fails.
- ///
- /// The bound document.
- /// The result, so a caller can read the partial-coverage warnings of check 5.
- /// The document fails one or more checks.
+ /// Runs and throws when it finds an error.
+ /// The loaded document.
+ /// The result, so the caller can read the warnings.
+ /// The document has one or more errors.
public static ConfigurationValidationResult ValidateStructure(AgentCoreConfiguration configuration)
{
return ThrowOnErrors(EvaluateStructure(configuration));
}
///
- /// Resolves every tool reference in the document against the ids the tool registry actually
- /// serves, and throws when one names an id nothing serves.
+ /// Checks every tool reference against the tool ids the registry serves. Call it after the MCP
+ /// servers have listed their tools.
///
- /// The bound document.
+ /// The loaded document.
/// Every tool id the registry serves, declared and MCP-discovered alike.
/// A reference names a tool id nothing serves.
public static void ValidateToolReferences(AgentCoreConfiguration configuration, IReadOnlySet servedToolIds)
@@ -106,11 +110,10 @@ public static void ValidateToolReferences(AgentCoreConfiguration configuration,
}
///
- /// Resolves every skills: and pinned: entry against the names the bound skills
- /// folder serves.
+ /// Checks every skills: and pinned: entry against the skills in the skills folder.
///
- /// The bound document.
- /// Every skill name the bound folder serves.
+ /// The loaded document.
+ /// Every skill name in the skills folder.
/// A reference names a skill nothing serves, or a skill is both pinned and loadable.
public static void ValidateSkillReferences(AgentCoreConfiguration configuration, IReadOnlySet servedSkillNames)
{
@@ -124,11 +127,10 @@ public static void ValidateSkillReferences(AgentCoreConfiguration configuration,
}
///
- /// Refuses a declared tool id that the skills provider also registers. The provider's tools are
- /// added per agent and never pass through the tool registry, so a collision is invisible until
- /// the model receives two tools of one name.
+ /// Refuses a declared tool id that has the same name as a skills tool. Skills tools do not go
+ /// through the tool registry, so without this check the model would get two tools with one name.
///
- /// The bound document.
+ /// The loaded document.
/// A tool id collides with a skills tool name.
public static void ValidateSkillToolNames(AgentCoreConfiguration configuration)
{
diff --git a/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs b/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs
new file mode 100644
index 00000000..82c1473e
--- /dev/null
+++ b/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs
@@ -0,0 +1,61 @@
+using AgentCore.Application.Configuration.Parsing;
+using AgentCore.Application.Configuration.Schema;
+using static AgentCore.Application.Configuration.Validation.ValidationErrors;
+
+namespace AgentCore.Application.Configuration.Validation
+{
+ ///
+ /// An agent used as a node of any graph may not declare background:.
+ ///
+ internal static class GraphNodeBackgroundCheck
+ {
+ public static void Run(AgentCoreConfiguration configuration, List errors)
+ {
+ Dictionary nodeEntry = NodeAgentEntries(configuration);
+ IReadOnlyList items = configuration.Agents.Items;
+
+ for (int index = 0; index < items.Count; index++)
+ {
+ AgentConfiguration agent = items[index];
+ if (agent.Background.Count > 0 && nodeEntry.TryGetValue(agent.Id, out string? entryName))
+ {
+ errors.Add(GraphNodeBackground(
+ ConfigurationError.AppendPointer(ValidationPointer.Agent(index), "background"),
+ $"the agent '{agent.Id}' declares background: and is also used as a node in the graph "
+ + $"of entry '{entryName}'. MAF has no way to release a graph node's session, so a "
+ + "background child started inside a node would keep running after the conversation "
+ + "ends. Remove background: from this agent, or stop using it as a graph node."));
+ }
+ }
+ }
+
+ /// The first entry name that binds each agent id as a graph node, pattern or explicit alike.
+ private static Dictionary NodeAgentEntries(AgentCoreConfiguration configuration)
+ {
+ Dictionary nodeEntry = new(StringComparer.Ordinal);
+
+ foreach ((string? name, EntryConfiguration? entry) in configuration.Entries)
+ {
+ if (entry.Graph is not { } graph)
+ {
+ continue;
+ }
+
+ foreach (string agentId in graph.Agents)
+ {
+ _ = nodeEntry.TryAdd(agentId, name);
+ }
+
+ foreach (GraphNodeConfiguration node in graph.Nodes)
+ {
+ if (node.Agent is { } agentId)
+ {
+ _ = nodeEntry.TryAdd(agentId, name);
+ }
+ }
+ }
+
+ return nodeEntry;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs b/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs
index 58b265cb..51e47671 100644
--- a/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs
+++ b/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs
@@ -44,6 +44,11 @@ public static ConfigurationError WellFormedness(string pointer, string message)
return new() { Pointer = pointer, Message = message, Check = ConfigurationCheck.GraphWellFormedness };
}
+ public static ConfigurationError GraphNodeBackground(string pointer, string message)
+ {
+ return new() { Pointer = pointer, Message = message, Check = ConfigurationCheck.GraphNodeBackground };
+ }
+
/// Refuses a count or an interval that falls outside the range the runtime accepts.
/// The configured value.
/// The lowest accepted value, inclusive.
diff --git a/src/AgentCore.Application/Conversation/ConversationInUseException.cs b/src/AgentCore.Application/Conversation/ConversationInUseException.cs
new file mode 100644
index 00000000..e6356e81
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/ConversationInUseException.cs
@@ -0,0 +1,48 @@
+namespace AgentCore.Application.Conversation
+{
+ ///
+ /// The session owner refused to open a conversation because another entry holds it live, or is opening it.
+ ///
+ public sealed class ConversationInUseException : InvalidOperationException
+ {
+ private const string DefaultMessage = "Another entry holds this conversation live, so the open was refused.";
+
+ /// Creates the exception with its default message.
+ public ConversationInUseException()
+ : base(DefaultMessage)
+ {
+ }
+
+ /// Creates the exception with a plain message.
+ /// The message a human reads.
+ public ConversationInUseException(string message)
+ : base(message)
+ {
+ }
+
+ /// Creates the exception with a plain message and an inner cause.
+ /// The message a human reads.
+ /// The cause.
+ public ConversationInUseException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+
+ /// Creates the exception for one refused open.
+ /// The id of the conversation that is held.
+ /// The entry that holds it live.
+ /// The entry whose open was refused.
+ public ConversationInUseException(string conversationId, string holdingEntry, string refusedEntry)
+ : base($"Entry '{holdingEntry}' holds conversation '{conversationId}' live, so entry '{refusedEntry}' was refused.")
+ {
+ ConversationId = conversationId;
+ HoldingEntry = holdingEntry;
+ }
+
+ /// Gets the id of the conversation that is held, or when the thrower named none.
+ public string? ConversationId { get; }
+
+ /// Gets the entry that holds the conversation live, or when the thrower named none.
+ public string? HoldingEntry { get; }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs b/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs
new file mode 100644
index 00000000..e673c6f5
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs
@@ -0,0 +1,33 @@
+namespace AgentCore.Application.Conversation
+{
+ ///
+ /// The store refused a turn's words because the conversation already saved that turn: another session of
+ /// the same conversation ran the same turn at the same time, and its commit landed first. Nothing of the
+ /// refused append was written.
+ ///
+ public sealed class ConversationTurnConflictException : InvalidOperationException
+ {
+ private const string DefaultMessage = "The conversation already saved this turn, so the store refused it.";
+
+ /// Creates the exception with its default message.
+ public ConversationTurnConflictException()
+ : base(DefaultMessage)
+ {
+ }
+
+ /// Creates the exception with a plain message.
+ /// The message a human reads.
+ public ConversationTurnConflictException(string message)
+ : base(message)
+ {
+ }
+
+ /// Creates the exception with a plain message and an inner cause.
+ /// The message a human reads.
+ /// The cause.
+ public ConversationTurnConflictException(string message, Exception? innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Conversations.cs b/src/AgentCore.Application/Conversation/Conversations.cs
index a65e6153..5d999f22 100644
--- a/src/AgentCore.Application/Conversation/Conversations.cs
+++ b/src/AgentCore.Application/Conversation/Conversations.cs
@@ -186,6 +186,12 @@ public ValueTask RewriteAsync(string conversationId, string messageId, ChatMessa
return Store.RewriteAsync(conversationId, messageId, content, cancellationToken);
}
+ ///
+ public ValueTask DeleteMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
+ {
+ return Store.DeleteMessageAsync(conversationId, messageId, cancellationToken);
+ }
+
///
public ValueTask> ReadForSessionAsync(string conversationId, CancellationToken cancellationToken = default)
{
@@ -222,6 +228,18 @@ public ValueTask SweepAsync(TimeSpan retention, int batchSize = 500, Cancel
return Store.SweepAsync(retention, batchSize, cancellationToken);
}
+ ///
+ public ValueTask TryMarkBusyAsync(string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default)
+ {
+ return Store.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken);
+ }
+
+ ///
+ public ValueTask ClearBusyAsync(string conversationId, string holder, CancellationToken cancellationToken = default)
+ {
+ return Store.ClearBusyAsync(conversationId, holder, cancellationToken);
+ }
+
///
public ValueTask AttachPrincipalAsync(string conversationId, string principalKey, string role, CancellationToken cancellationToken = default)
{
@@ -235,21 +253,21 @@ public ValueTask DetachPrincipalAsync(string conversationId, string principalKey
}
///
- public ValueTask SaveContinuationAsync(string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default)
+ public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default)
{
- return Store.SaveContinuationAsync(continuationId, conversationId, envelope, cancellationToken);
+ return Store.SaveContinuationAsync(responseId, conversationId, cancellationToken);
}
///
- public ValueTask GetContinuationAsync(string continuationId, CancellationToken cancellationToken = default)
+ public ValueTask FindContinuationAsync(string responseId, CancellationToken cancellationToken = default)
{
- return Store.GetContinuationAsync(continuationId, cancellationToken);
+ return Store.FindContinuationAsync(responseId, cancellationToken);
}
///
- public ValueTask DeleteContinuationAsync(string continuationId, CancellationToken cancellationToken = default)
+ public ValueTask DeleteContinuationAsync(string responseId, CancellationToken cancellationToken = default)
{
- return Store.DeleteContinuationAsync(continuationId, cancellationToken);
+ return Store.DeleteContinuationAsync(responseId, cancellationToken);
}
}
}
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs
new file mode 100644
index 00000000..3e371c77
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs
@@ -0,0 +1,48 @@
+namespace AgentCore.Application.Conversation.Memory
+{
+ /// The busy mark of each conversation the in-memory store holds, beside its rows rather than on them.
+ /// The clock a mark lapses on.
+ internal sealed class InMemoryConversationBusyMarks(TimeProvider time)
+ {
+ private readonly Lock _lock = new();
+
+ private readonly Dictionary _marks = [];
+
+ /// Puts or extends a holder's mark, unless another holder's mark is still live.
+ /// The conversation to mark.
+ /// Who holds the mark.
+ /// How long the mark lives from now.
+ /// when now holds the mark.
+ public bool TryMark(string conversationId, string holder, TimeSpan lease)
+ {
+ lock (_lock)
+ {
+ DateTimeOffset now = time.GetUtcNow();
+
+ if (_marks.TryGetValue(conversationId, out (string Holder, DateTimeOffset Until) mark)
+ && mark.Holder != holder
+ && mark.Until > now)
+ {
+ return false;
+ }
+
+ _marks[conversationId] = (holder, now + lease);
+ return true;
+ }
+ }
+
+ /// Takes a holder's mark off, and leaves another holder's mark as it is.
+ /// The conversation to clear.
+ /// The id the mark was put under.
+ public void Clear(string conversationId, string holder)
+ {
+ lock (_lock)
+ {
+ if (_marks.TryGetValue(conversationId, out (string Holder, DateTimeOffset Until) mark) && mark.Holder == holder)
+ {
+ _ = _marks.Remove(conversationId);
+ }
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs
index b3897e1e..a9cb5aca 100644
--- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs
@@ -1,70 +1,70 @@
-using System.Text.Json;
-
namespace AgentCore.Application.Conversation.Memory
{
///
- /// One serialized agent session per continuation id, held beside the in-memory store's conversations.
- /// Every member runs under the owning store's lock; nothing here takes one.
+ /// One row per response id, held beside the in-memory store's conversations.
///
internal sealed class InMemoryConversationContinuations
{
- private readonly Dictionary _envelopes = [];
+ private readonly Dictionary _rows = [];
- /// Files one session envelope under one continuation id, replacing any envelope already there.
- /// The continuation id: a conversation id or a response id.
- /// The conversation the continuation belongs to.
- /// The serialized session, as the agent wrote it.
- /// When this write happened. A rewrite of the same id resets its retention clock.
- public void Save(string continuationId, string conversationId, JsonElement envelope, DateTimeOffset updatedAt)
+ ///
+ /// Records that one response id continues one conversation.
+ ///
+ /// The response id to record.
+ /// The conversation the response id continues.
+ /// When this row was written, for the retention sweep.
+ public void Save(string responseId, string conversationId, DateTimeOffset createdAt)
{
- _envelopes[continuationId] = (conversationId, envelope.Clone(), updatedAt);
+ if (!_rows.ContainsKey(responseId))
+ {
+ _rows[responseId] = (conversationId, createdAt);
+ }
}
- /// Reads the envelope one continuation id names.
- /// The continuation id to look up.
- /// The envelope, or when nothing is filed under that id.
- public JsonElement? Get(string continuationId)
+ /// Finds the conversation one response id continues.
+ /// The response id to look up.
+ /// The conversation id, or when no row names that response id.
+ public string? Find(string responseId)
{
- return _envelopes.TryGetValue(continuationId, out (string ConversationId, JsonElement Envelope, DateTimeOffset UpdatedAt) row)
- ? row.Envelope
+ return _rows.TryGetValue(responseId, out (string ConversationId, DateTimeOffset CreatedAt) row)
+ ? row.ConversationId
: null;
}
- /// Withdraws whatever one continuation id names, if anything.
- /// The continuation id to forget.
- public void Forget(string continuationId)
+ /// Withdraws one response id, if it names a row.
+ /// The response id to forget.
+ public void Forget(string responseId)
{
- _ = _envelopes.Remove(continuationId);
+ _ = _rows.Remove(responseId);
}
- /// Withdraws every continuation filed under one conversation, however it was keyed.
- /// The conversation whose continuations are gone.
+ /// Withdraws every response id filed under one conversation.
+ /// The conversation whose response ids are gone.
public void ForgetConversation(string conversationId)
{
- foreach (string continuationId in _envelopes
+ foreach (string responseId in _rows
.Where(pair => pair.Value.ConversationId == conversationId)
.Select(pair => pair.Key)
.ToList())
{
- _ = _envelopes.Remove(continuationId);
+ _ = _rows.Remove(responseId);
}
}
///
- /// Withdraws every continuation row untouched since before the cutoff, whichever conversation
- /// it belongs to.
+ /// Withdraws every response id written before the cutoff, whichever conversation it continues.
///
/// The point in time a row must have been written on or after, to stay.
/// How many rows went.
public int Sweep(DateTimeOffset cutoff)
{
- List going = [.. _envelopes
- .Where(pair => pair.Value.UpdatedAt < cutoff)
+ List going = [.. _rows
+ .Where(pair => pair.Value.CreatedAt < cutoff)
.Select(pair => pair.Key)];
- foreach (string continuationId in going)
+ foreach (string responseId in going)
{
- _ = _envelopes.Remove(continuationId);
+ _ = _rows.Remove(responseId);
}
return going.Count;
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
index 553ec0d8..47e40d91 100644
--- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
@@ -25,6 +25,8 @@ public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null)
private readonly TimeProvider _time = timeProvider ?? TimeProvider.System;
+ private readonly InMemoryConversationBusyMarks _busy = new(timeProvider ?? TimeProvider.System);
+
///
public ValueTask CreateAsync(string conversationId, CancellationToken cancellationToken = default)
{
@@ -151,6 +153,20 @@ public ValueTask SweepAsync(
}
}
+ ///
+ public ValueTask TryMarkBusyAsync(
+ string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(_busy.TryMark(conversationId, holder, lease));
+ }
+
+ ///
+ public ValueTask ClearBusyAsync(string conversationId, string holder, CancellationToken cancellationToken = default)
+ {
+ _busy.Clear(conversationId, holder);
+ return default;
+ }
+
///
public ValueTask AttachPrincipalAsync(
string conversationId, string principalKey, string role, CancellationToken cancellationToken = default)
@@ -205,12 +221,18 @@ public ValueTask> AppendAsync(
}
int fallbackTurnIndex = _state.GetValueOrDefault(conversationId)?.NextTurnIndex ?? 0;
+ if (messages.Min(message => message.TurnIndex) is { } named && named < fallbackTurnIndex)
+ {
+ throw new ConversationTurnConflictException(
+ $"Conversation '{conversationId}' already saved turn {named} and takes turn {fallbackTurnIndex} next, so nothing was written.");
+ }
+
IReadOnlyList rows = _words.Append(conversationId, messages, fallbackTurnIndex);
DateTimeOffset now = _time.GetUtcNow();
_conversations[conversationId] = conversation with { LastMessageAt = now };
- if (state is not null)
+ if (state is not null && state.NextTurnIndex >= fallbackTurnIndex)
{
_state[conversationId] = state;
}
@@ -235,6 +257,20 @@ public ValueTask RewriteAsync(
return default;
}
+ ///
+ public ValueTask DeleteMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(conversationId);
+ ArgumentException.ThrowIfNullOrEmpty(messageId);
+
+ lock (_lock)
+ {
+ _words.Delete(conversationId, messageId);
+ }
+
+ return default;
+ }
+
///
public ValueTask> ReadForSessionAsync(
string conversationId, CancellationToken cancellationToken = default)
@@ -296,38 +332,38 @@ public ValueTask EraseAsync(string conversationId, CancellationToken cancel
}
///
- public ValueTask SaveContinuationAsync(string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default)
+ public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(continuationId);
+ ArgumentNullException.ThrowIfNull(responseId);
ArgumentNullException.ThrowIfNull(conversationId);
lock (_lock)
{
- _continuations.Save(continuationId, conversationId, envelope, _time.GetUtcNow());
+ _continuations.Save(responseId, conversationId, _time.GetUtcNow());
}
return default;
}
///
- public ValueTask GetContinuationAsync(string continuationId, CancellationToken cancellationToken = default)
+ public ValueTask FindContinuationAsync(string responseId, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(continuationId);
+ ArgumentNullException.ThrowIfNull(responseId);
lock (_lock)
{
- return ValueTask.FromResult(_continuations.Get(continuationId));
+ return ValueTask.FromResult(_continuations.Find(responseId));
}
}
///
- public ValueTask DeleteContinuationAsync(string continuationId, CancellationToken cancellationToken = default)
+ public ValueTask DeleteContinuationAsync(string responseId, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(continuationId);
+ ArgumentNullException.ThrowIfNull(responseId);
lock (_lock)
{
- _continuations.Forget(continuationId);
+ _continuations.Forget(responseId);
}
return default;
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs
index d5b99ff9..8f0cd448 100644
--- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs
@@ -70,6 +70,21 @@ public void Rewrite(string conversationId, string messageId, ChatMessage content
}
}
+ /// Deletes one row, by the name the caller knows it by. The rows around it keep their ordinals.
+ /// The conversation the row belongs to.
+ /// The row to delete.
+ public void Delete(string conversationId, string messageId)
+ {
+ foreach ((string ConversationId, int Ordinal) key in _rows.Keys)
+ {
+ if (key.ConversationId == conversationId && _rows[key].MessageId == messageId)
+ {
+ _ = _rows.Remove(key);
+ break;
+ }
+ }
+ }
+
private IReadOnlyList Read(string conversationId)
{
return [.. Said(conversationId).OrderBy(row => row.Ordinal)];
diff --git a/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs b/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs
index 4c8d9707..9b6df4c2 100644
--- a/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs
+++ b/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs
@@ -32,6 +32,9 @@ public static class AgentCoreTelemetry
/// The failure kind of section 8.7, last row: the run returned quietly with no text.
internal const string FailureEmptyReply = "empty_reply";
+ /// The failure kind of a run that faulted outside every tool, say a model endpoint that did not answer.
+ internal const string FailureRun = "run";
+
/// The failure kind of section 8.7, row two: the extractor returned an invalid object.
internal const string FailureExtraction = "extraction";
@@ -48,10 +51,54 @@ public static class AgentCoreTelemetry
private static readonly Meter Instruments = new(MeterName);
+ // Bucket boundaries for every agentcore.* histogram below, in seconds. The SDK default bounds are
+ // millisecond-shaped ([0,5,10,…,10000]) and put every reading here in the first bucket or two, which
+ // makes a percentile query meaningless (docs/probes/r2/otel/REPORT.md, audit finding F1). The values
+ // are the OpenTelemetry GenAI semantic conventions' own advice, so a backend that already has GenAI
+ // dashboards for gen_ai.client.operation.duration / gen_ai.server.time_to_first_token gets the same
+ // resolution for these:
+ // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md
+ private static readonly InstrumentAdvice OperationDurationBuckets = new()
+ {
+ HistogramBucketBoundaries =
+ [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92],
+ };
+
+ private static readonly InstrumentAdvice TimeToFirstTokenBuckets = new()
+ {
+ HistogramBucketBoundaries =
+ [0.001, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0],
+ };
+
private static readonly Histogram TurnDuration = Instruments.CreateHistogram(
"agentcore.turn.duration",
unit: "s",
- description: "How long one turn took, from the caller's words to the spoken line.");
+ description: "How long one turn took, from the user's words to the reply.",
+ advice: OperationDurationBuckets);
+
+ private static readonly Histogram TimeToFirstToken = Instruments.CreateHistogram(
+ "agentcore.turn.time_to_first_token",
+ unit: "s",
+ description: "From the start of one model step to its first text or tool call. One reading per step.",
+ advice: TimeToFirstTokenBuckets);
+
+ private static readonly Histogram TimeToFirstSpeech = Instruments.CreateHistogram(
+ "agentcore.turn.time_to_first_speech",
+ unit: "s",
+ description: "From the user's final words to the first text of the reply handed to the output.",
+ advice: TimeToFirstTokenBuckets);
+
+ private static readonly Histogram TimeToReplyEnd = Instruments.CreateHistogram(
+ "agentcore.turn.time_to_reply_end",
+ unit: "s",
+ description: "From the user's final words to the end of the last step of a reply nothing cut short.",
+ advice: OperationDurationBuckets);
+
+ private static readonly Histogram BargeInLatency = Instruments.CreateHistogram(
+ "agentcore.barge_in.latency",
+ unit: "s",
+ description: "From the transport reporting a barge-in to every speech it interrupted being done.",
+ advice: TimeToFirstTokenBuckets);
private static readonly Counter TurnFailures = Instruments.CreateCounter(
"agentcore.turn.failures",
@@ -98,13 +145,24 @@ public static class AgentCoreTelemetry
/// One of the three closed outcome values.
/// The stage the machine holds after the turn.
/// The section 8.7 reason, or when the turn answered.
+ ///
+ /// The OTel error.type value for a failed turn: an exception's full type name, or one of the
+ /// closed failure-kind tokens when nothing threw. keeps
+ /// itself as the span status, so a fixed, non-PII reason (such as a refused-turn message) still reads
+ /// on the span. Never a value that can carry caller or model text: that belongs in the log, not here.
+ ///
internal static void EndTurn(
Activity? activity,
TimeSpan elapsed,
string outcome,
string stageAfter,
- string? failure)
+ string? failure,
+ string? errorType = null)
{
+ // error.type never rides the metric point, even though the SPAN carries it below: an exception's
+ // type name is an open set (T61, and TurnObservabilityTests' PermittedMetricKeys), and a
+ // cumulative series per distinct type would grow without bound the same way one per conversation
+ // id would.
TurnDuration.Record(elapsed.TotalSeconds, new KeyValuePair("agentcore.turn.outcome", outcome));
if (activity is null)
@@ -121,14 +179,57 @@ internal static void EndTurn(
if (failure is not null)
{
- // A failed turn still speaks a line, so the conversation is alive. The span says the turn failed
- // and the reason names the row of section 8.7 it met.
- _ = activity.SetStatus(ActivityStatusCode.Error, failure);
+ // A failed turn still speaks a line, so the conversation is alive. The span says the turn
+ // failed. Callers that pass errorType never also pass a failure built from an exception's
+ // message, so the status never carries caller or model text (owner ruling: type in spans,
+ // full error in logs).
+ _ = activity.SetStatus(ActivityStatusCode.Error, errorType ?? failure);
+
+ if (errorType is not null)
+ {
+ _ = activity.SetTag("error.type", errorType);
+ }
}
}
+ /// Records how long the model took to say its first word of one turn.
+ /// Time since the transport handed over the user's words.
+ internal static void RecordTimeToFirstToken(TimeSpan elapsed)
+ {
+ TimeToFirstToken.Record(elapsed.TotalSeconds);
+ }
+
+ /// Records how long one turn took to hand its first fragment back to the transport.
+ /// Time since the transport handed over the user's words.
+ ///
+ /// This is the last point the library can see. A transport that synthesises the audio itself
+ /// adds its own time to first sound on top, and no number here can reach it.
+ ///
+ internal static void RecordTimeToFirstSpeech(TimeSpan elapsed)
+ {
+ TimeToFirstSpeech.Record(elapsed.TotalSeconds);
+ }
+
+ /// Records how long one whole reply took to hand over.
+ /// Time since the transport handed over the user's words.
+ ///
+ /// Only replies that ended on their own are recorded. A barge-in ends a turn before its close,
+ /// so an interrupted turn contributes nothing and cannot pull the distribution down.
+ ///
+ internal static void RecordTimeToReplyEnd(TimeSpan elapsed)
+ {
+ TimeToReplyEnd.Record(elapsed.TotalSeconds);
+ }
+
+ /// Records how long a barge-in took to take effect.
+ /// Time from the transport reporting the barge-in to the queued reply being dropped.
+ internal static void RecordBargeInLatency(TimeSpan elapsed)
+ {
+ BargeInLatency.Record(elapsed.TotalSeconds);
+ }
+
/// Counts one turn that met a section 8.7 failure row.
- /// One of the three closed failure kinds.
+ /// One of the four closed failure kinds.
internal static void RecordFailure(string kind)
{
TurnFailures.Add(1, new KeyValuePair("agentcore.failure.kind", kind));
diff --git a/src/AgentCore.Application/Diagnostics/Log.cs b/src/AgentCore.Application/Diagnostics/Log.cs
index d8edb07f..840cd3ba 100644
--- a/src/AgentCore.Application/Diagnostics/Log.cs
+++ b/src/AgentCore.Application/Diagnostics/Log.cs
@@ -5,7 +5,9 @@
namespace AgentCore.Application.Diagnostics
{
///
- /// Every line the library writes. Three of them are the "log once" rows of section 8.7.
+ /// Every line the turn loop writes: extraction, moderation, the transcript, and knowledge retrieval. Three
+ /// of them are the "log once" rows of section 8.7. The session owner's own lines — its workspace folder,
+ /// and its close and idle-expiry routine — are .
///
internal static partial class Log
{
@@ -25,13 +27,27 @@ internal static partial class Log
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that just ran.
- /// The message of the fault.
+ /// The fault the fallback layer caught, message and stack trace both.
[LoggerMessage(
EventId = 2,
Level = LogLevel.Error,
- Message = "A tool of conversation {ConversationId} failed four times in turn {TurnIndex}: {Reason} "
+ Message = "A tool of conversation {ConversationId} failed four times in turn {TurnIndex}. "
+ "The turn spoke the fallback and the conversation continues.")]
- public static partial void ToolBudgetSpent(ILogger logger, string conversationId, int turnIndex, string reason);
+ public static partial void ToolBudgetSpent(ILogger logger, string conversationId, int turnIndex, Exception exception);
+
+ ///
+ /// Section 8.7, row six, the other cause of it: a fault above the fallback layer, so no tool ever ran.
+ ///
+ /// The logger of the session.
+ /// The id of the conversation.
+ /// The zero-based index of the turn that just ran.
+ /// The fault the run threw, message and stack trace both.
+ [LoggerMessage(
+ EventId = 28,
+ Level = LogLevel.Error,
+ Message = "The run of conversation {ConversationId} faulted in turn {TurnIndex}. "
+ + "The turn spoke the fallback and the conversation continues.")]
+ public static partial void TurnRunFaulted(ILogger logger, string conversationId, int turnIndex, Exception exception);
/// Section 8.7, last row. The run returned quietly with no text.
/// The logger of the session.
@@ -113,18 +129,6 @@ internal static partial class Log
+ "The conversation continues and the turn has no durable record.")]
public static partial void TranscriptWriteFailed(ILogger logger, string conversationId, int turnIndex, Exception exception);
- /// A barge-in cut a reply, so the record now holds what the caller heard.
- /// The logger of the session.
- /// The id of the conversation.
- /// The zero-based index of the turn whose reply was cut.
- /// How much of the reply was played, as the vendor reported it.
- [LoggerMessage(
- EventId = 10,
- Level = LogLevel.Debug,
- Message = "A barge-in cut the reply of turn {TurnIndex} of conversation {ConversationId} "
- + "after {PlayedMilliseconds} ms, so the record holds what the caller heard.")]
- public static partial void ReplyTruncated(ILogger logger, string conversationId, int turnIndex, double playedMilliseconds);
-
/// One knowledge retrieval answered, with what it cost and what it returned.
/// The logger of the knowledge provider.
/// The id of the agent that asked.
@@ -251,36 +255,6 @@ public static partial void KnowledgeScopeComposed(
+ "turn since the last read is not among them.")]
public static partial void TranscriptResyncFailed(ILogger logger, string conversationId, int turnIndex, Exception exception);
- /// A conversation's workspace folder could not be deleted when the conversation ended.
- /// The logger of the session.
- /// The folder that could not be deleted.
- /// The cause.
- [LoggerMessage(
- EventId = 20,
- Level = LogLevel.Warning,
- Message = "Could not delete the conversation workspace at '{Path}'.")]
- public static partial void WorkspaceDeleteFailed(ILogger logger, string path, Exception exception);
-
- /// A conversation's shell executor could not be disposed when the conversation ended.
- /// The logger of the session.
- /// The conversation's workspace folder, whose shell failed to dispose.
- /// The cause.
- [LoggerMessage(
- EventId = 21,
- Level = LogLevel.Warning,
- Message = "Could not dispose a shell: executor of the conversation at workspace '{Workspace}'.")]
- public static partial void ShellDisposeFailed(ILogger logger, string workspace, Exception exception);
-
- /// A conversation's background agent sessions could not be released when the conversation ended.
- /// The logger of the session.
- /// The id of the conversation whose children were being released.
- /// The cause.
- [LoggerMessage(
- EventId = 22,
- Level = LogLevel.Warning,
- Message = "Could not release the background agent sessions of the conversation '{ConversationId}'.")]
- public static partial void BackgroundReleaseFailed(ILogger logger, string conversationId, Exception exception);
-
/// A workspace file could not be published: the run had no call to own it.
/// The logger of the tool.
/// The tool the model called.
@@ -331,5 +305,39 @@ public static partial void KnowledgeScopeComposed(
Message = "The compaction strategy of conversation {ConversationId} produced, before turn {TurnIndex}, a shape one summary row "
+ "cannot stand for: not one new message over the oldest rows with the rest kept in order. The session keeps the view it had.")]
public static partial void TranscriptCompactionUnsupported(ILogger logger, string conversationId, int turnIndex);
+
+ /// A turn was refused or dropped, so none of its words were kept.
+ /// The logger of the session.
+ /// The id of the conversation.
+ /// The turn the session would have run.
+ /// The token.
+ [LoggerMessage(
+ EventId = 40,
+ Level = LogLevel.Warning,
+ Message = "Turn {TurnIndex} of conversation {ConversationId} was refused ({Reason}), so none of its words "
+ + "were kept.")]
+ public static partial void TurnRefused(ILogger logger, string conversationId, int turnIndex, string reason);
+
+ /// The store could not put, renew or clear the conversation's busy mark.
+ /// The logger of the session.
+ /// The id of the conversation.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 41,
+ Level = LogLevel.Warning,
+ Message = "The busy mark of conversation {ConversationId} could not be written. The turn runs on; the "
+ + "store still refuses a turn another session saved first.")]
+ public static partial void BusyMarkFailed(ILogger logger, string conversationId, Exception exception);
+
+ /// A running turn found its busy mark taken by another session when it came to renew it.
+ /// The logger of the session.
+ /// The id of the conversation.
+ [LoggerMessage(
+ EventId = 42,
+ Level = LogLevel.Warning,
+ Message = "The busy mark of conversation {ConversationId} lapsed while a turn still ran, and another "
+ + "session took it. The store refuses whichever of the two turns saves second.")]
+ public static partial void BusyMarkLost(ILogger logger, string conversationId);
+
}
}
diff --git a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs
index 0612797c..82b1be94 100644
--- a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs
+++ b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs
@@ -9,7 +9,6 @@ namespace AgentCore.Application.Diagnostics
///
/// Writes the "log once" rows of section 8.7, one line for each fact that earns one.
///
- /// Creates the observer that writes the lines of section 8.7.
///
/// Where the lines go, or for . The
/// library never throws for want of one.
@@ -28,37 +27,11 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
switch (conversationEvent.Kind)
{
- case ConversationEventKind.ToolFailed:
- // Section 8.7, row six. The turn spoke the fallback and the conversation stays alive, so
- // nothing here is fatal, and the level says Error because a spent retry budget is a
- // defect in the tool.
- //
- // This kind is now raised at two altitudes, and only the TURN one earns a line. A
- // fact that carries a tool call id is one failing conversation, and a turn spends up to four
- // of them; the chain of D23 keeps every one, which is where a record of a conversation
- // belongs, and section 8.7 asks for one line and not four. The turn-level fact
- // carries no conversation id, because the fault reaches the session with no function name on
- // it at all, so the absent key is exactly the test.
- if (conversationEvent.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))
- {
- break;
- }
-
- Log.ToolBudgetSpent(
- _logger,
- conversationEvent.ConversationId,
- TurnOf(conversationEvent),
- Detail(conversationEvent, AuditPayloadKeys.ToolError));
- break;
-
case ConversationEventKind.EmptyReply:
- // Section 8.7, last row. On a voice call the silence is the failure.
Log.EmptyReply(_logger, conversationEvent.ConversationId, TurnOf(conversationEvent));
break;
case ConversationEventKind.ExtractionFailed:
- // Section 8.7, row two. The slots stay unchanged and the conversation continues, so this is a
- // warning and never an error.
Log.ExtractionFailed(
_logger,
conversationEvent.ConversationId,
@@ -75,7 +48,6 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
break;
case ConversationEventKind.ModerationUnavailable:
- // A vendor that did not answer is not a defect in this library, so it is a warning.
Log.ModerationUnavailable(
_logger,
conversationEvent.ConversationId,
@@ -84,27 +56,32 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
break;
case ConversationEventKind.StateRestorePartial:
- // Not a turn's fact and not section 8.7's: it happens once, as the conversation opens, and it
- // carries no turn index. It earns a line all the same because it is the only signal
- // there is. A document change costs a resumed conversation its stage or a slot, the chain
- // stores no diagnostic kind, and the caller notices only that the agent has forgotten
- // them — so without this line the fault is visible to nobody but a host that wrote an
- // IConversationObserver of its own.
Log.StateRestorePartial(
_logger,
conversationEvent.ConversationId,
Detail(conversationEvent, ConversationEventPayloadKeys.Reason));
break;
+ case ConversationEventKind.TurnRefused:
+ Log.TurnRefused(
+ _logger,
+ conversationEvent.ConversationId,
+ TurnOf(conversationEvent),
+ Detail(conversationEvent, AuditPayloadKeys.RefusedReason));
+ break;
+
case ConversationEventKind.ConversationStarted:
case ConversationEventKind.ModerationClean:
case ConversationEventKind.TurnCompleted:
case ConversationEventKind.ReplyInterrupted:
case ConversationEventKind.TurnSuperseded:
- case ConversationEventKind.TranscriptWriteFailed:
case ConversationEventKind.ConversationEnded:
+ break;
+
+ case ConversationEventKind.TranscriptWriteFailed:
case ConversationEventKind.TranscriptResyncFailed:
- // The chain of D23 is the record of a normal conversation, and a log is not.
+ case ConversationEventKind.ToolFailed:
+ case ConversationEventKind.RunFaulted:
break;
default:
@@ -126,11 +103,6 @@ private static int TurnOf(ConversationEvent conversationEvent)
/// The fact being reported.
/// The payload key the detail sits under.
/// The detail, or an empty string when the fact carried none.
- ///
- /// A missing fact is an absent key and never the word "unknown", per the rule the turn loop keeps
- /// for a payload. The line is still written: what happened is the point of it, and the detail is
- /// the part a reader may have to do without.
- ///
private static string Detail(ConversationEvent conversationEvent, string key)
{
return conversationEvent.Payload.TryGetValue(key, out string? detail) ? detail : string.Empty;
diff --git a/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs b/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs
new file mode 100644
index 00000000..4dc703c2
--- /dev/null
+++ b/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs
@@ -0,0 +1,126 @@
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Diagnostics
+{
+ ///
+ /// Every line the session owner writes: a conversation's workspace folder, and the close or idle-expiry
+ /// routine that deletes it, disposes its shells, and releases its background sessions.
+ ///
+ internal static partial class SessionOwnerLog
+ {
+ /// A conversation's workspace folder could not be deleted when its session closed or unloaded.
+ /// The logger of the session.
+ /// The folder that could not be deleted.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 20,
+ Level = LogLevel.Warning,
+ Message = "Could not delete the conversation workspace at '{Path}'.")]
+ public static partial void WorkspaceDeleteFailed(ILogger logger, string path, Exception exception);
+
+ /// A conversation's shell executor could not be disposed when its session closed or unloaded.
+ /// The logger of the session.
+ /// The conversation's workspace folder, whose shell failed to dispose.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 21,
+ Level = LogLevel.Warning,
+ Message = "Could not dispose a shell: executor of the conversation at workspace '{Workspace}'.")]
+ public static partial void ShellDisposeFailed(ILogger logger, string workspace, Exception exception);
+
+ /// A conversation's background agent sessions could not be released when its session closed or unloaded.
+ /// The logger of the session.
+ /// The id of the conversation whose children were being released.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 22,
+ Level = LogLevel.Warning,
+ Message = "Could not release the background agent sessions of the conversation '{ConversationId}'.")]
+ public static partial void BackgroundReleaseFailed(ILogger logger, string conversationId, Exception exception);
+
+ /// A background provider's release waited its whole timeout: a child did not acknowledge its cancel.
+ /// The logger of the session.
+ /// The id of the conversation whose children were being released.
+ /// How long the release waited, in seconds.
+ [LoggerMessage(
+ EventId = 43,
+ Level = LogLevel.Warning,
+ Message = "A background child of the conversation '{ConversationId}' did not stop within {TimeoutSeconds} s "
+ + "of its cancel. The session was released anyway, and the child may still be running.")]
+ public static partial void BackgroundReleaseTimedOut(ILogger logger, string conversationId, double timeoutSeconds);
+
+ /// An idle session could not be unloaded cleanly.
+ /// The logger of the session.
+ /// The id of the conversation that expired.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 44,
+ Level = LogLevel.Error,
+ Message = "The idle conversation '{ConversationId}' did not unload cleanly. It is no longer held, but "
+ + "its last words, its shells, or its workspace folder may not have been cleaned up.")]
+ public static partial void SessionExpiryFailed(ILogger logger, string conversationId, Exception exception);
+
+ /// The idle check could not read whether a background child is still running.
+ /// The logger of the session.
+ /// The id of the conversation.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 45,
+ Level = LogLevel.Warning,
+ Message = "Could not read whether conversation '{ConversationId}' has a background child still running. "
+ + "The idle check treats it as one, and holds the session until a later poll can read it cleanly.")]
+ public static partial void BackgroundChildCheckFailed(ILogger logger, string conversationId, Exception exception);
+
+ /// A conversation's workspace folder could not be stamped at the start of a turn.
+ /// The logger of the session.
+ /// The folder that could not be stamped.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 46,
+ Level = LogLevel.Warning,
+ Message = "Could not stamp the conversation workspace at '{Path}'. The boot sweep of another process "
+ + "sharing this workspace root may mistake it for one a crashed process left behind.")]
+ public static partial void WorkspaceStampFailed(ILogger logger, string path, Exception exception);
+
+ /// The boot sweep could not list the folders directly under the workspace root.
+ /// The boot's own logger.
+ /// The workspace root.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 47,
+ Level = LogLevel.Warning,
+ Message = "The boot sweep could not list the folders under workspace root '{Root}'. No folder was swept this boot.")]
+ public static partial void WorkspaceSweepListFailed(ILogger logger, string root, Exception exception);
+
+ /// The boot sweep found a folder older than the idle timeout, but could not delete it.
+ /// The boot's own logger.
+ /// The folder the sweep could not delete.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 48,
+ Level = LogLevel.Warning,
+ Message = "The boot sweep could not delete the workspace folder '{Path}' left behind by a crashed process.")]
+ public static partial void WorkspaceSweepDeleteFailed(ILogger logger, string path, Exception exception);
+
+ /// A conversation's workspace marker could not be written when its folder was created or reused.
+ /// The logger of the session.
+ /// The marker path that could not be written.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 49,
+ Level = LogLevel.Warning,
+ Message = "Could not write the workspace marker at '{Path}'. The folder still works, but the boot sweep "
+ + "will never consider it for cleanup.")]
+ public static partial void WorkspaceMarkerWriteFailed(ILogger logger, string path, Exception exception);
+
+ /// A conversation's workspace marker could not be deleted when its folder closed or unloaded.
+ /// The logger of the session.
+ /// The marker path that could not be deleted.
+ /// The cause.
+ [LoggerMessage(
+ EventId = 50,
+ Level = LogLevel.Warning,
+ Message = "Could not delete the workspace marker at '{Path}'.")]
+ public static partial void WorkspaceMarkerDeleteFailed(ILogger logger, string path, Exception exception);
+ }
+}
diff --git a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs
index eff10692..984e22e0 100644
--- a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs
+++ b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs
@@ -21,8 +21,6 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
break;
case ConversationEventKind.ModerationUnavailable:
- // The turn ran unchecked, because moderation fails open. This value is the only
- // record of that, so an operator alerts on it rather than on a log line.
AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationUnavailable);
break;
@@ -31,13 +29,21 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
break;
case ConversationEventKind.ToolFailed:
- AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureTool);
+ if (!conversationEvent.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))
+ {
+ AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureTool);
+ }
+
break;
case ConversationEventKind.EmptyReply:
AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureEmptyReply);
break;
+ case ConversationEventKind.RunFaulted:
+ AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureRun);
+ break;
+
case ConversationEventKind.ExtractionFailed:
AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureExtraction);
break;
@@ -46,31 +52,19 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C
case ConversationEventKind.TurnCompleted:
case ConversationEventKind.ReplyInterrupted:
case ConversationEventKind.TurnSuperseded:
+ case ConversationEventKind.TurnRefused:
case ConversationEventKind.ConversationEnded:
- // Counted below, as rows of the chain, and nowhere else.
break;
case ConversationEventKind.TranscriptWriteFailed:
case ConversationEventKind.StateRestorePartial:
case ConversationEventKind.TranscriptResyncFailed:
- // Counted NOWHERE. No instrument would take them: agentcore.turn.failures counts what
- // a TURN failed at, by a closed set of values T61 keeps closed because a new value costs
- // a permanent series. A refused store 1 write is a fault of the system rather than of
- // the turn, and a dropped slot happens before any turn has run, so each would need an
- // instrument of its own and not another value on that one. They are reported as log
- // lines instead, and saying so here is cheaper than an operator reading this switch and
- // assuming a counter exists.
break;
default:
break;
}
- // Section 8.6 counts the events the turn loop handed to the sink, by kind, and it counted them
- // whatever the sink then did with them: the old conversation sat at the top of ConversationSession.Append,
- // above the enqueue and above the try. A kind the chain does not store is not one of them, so
- // the six diagnostic kinds are counted by their failure or their verdict above, or not at all,
- // and never here.
if (ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind auditKind))
{
AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(auditKind));
diff --git a/src/AgentCore.Application/Ports/IConversationAdapter.cs b/src/AgentCore.Application/Ports/IConversationAdapter.cs
deleted file mode 100644
index 6a6522b7..00000000
--- a/src/AgentCore.Application/Ports/IConversationAdapter.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-namespace AgentCore.Application.Ports
-{
- ///
- /// Names the vendor behind one providers.conversation value: who carries the conversation.
- ///
- ///
- ///
- /// This is the selection face, and it is all the core can see. The routing face
- /// lives in AgentCore.AspNetCore as IConversationTransportAdapter, because it names
- /// IEndpointRouteBuilder and this assembly references no ASP.NET Core package.
- ///
- ///
- /// A vendor this process dials out to implements this and stops. It has no inbound URL, so
- /// it is never forced to grow a routing member it would have to throw from.
- ///
- ///
- public interface IConversationAdapter : IVendorAdapter
- {
- ///
- /// Gets whether this transport's frames already carry text.
- ///
- ///
- ///
- /// means the vendor performs recognition and synthesis itself — the
- /// Telnyx Conversation Relay does — so both speech roles,
- /// providers.speech.stt.kind and providers.speech.tts.kind, must name that same
- /// vendor, and enforces it.
- ///
- ///
- /// means the frames carry audio and a separate speech vendor turns it
- /// into text. That adapter runs recognition and synthesis inside itself: audio never leaves it,
- /// so no audio type enters this assembly either way.
- ///
- ///
- /// This is declared here rather than written in the document. A payload: text
- /// field beside kind could only agree with the vendor or lie about it, and an adapter
- /// cannot be wrong about its own wire.
- ///
- ///
- bool CarriesText { get; }
- }
-}
diff --git a/src/AgentCore.Application/Ports/IConversationInputPort.cs b/src/AgentCore.Application/Ports/IConversationInputPort.cs
deleted file mode 100644
index c8e9ef5b..00000000
--- a/src/AgentCore.Application/Ports/IConversationInputPort.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using AgentCore.Application.Conversation;
-
-namespace AgentCore.Application.Ports
-{
- ///
- /// What the caller did, for one conversation, as one ordered stream.
- ///
- ///
- ///
- /// One instance serves one conversation and is disposed with it. That is unlike every other port in this
- /// folder, which is bound once for the process, and it is why this one is
- /// .
- ///
- ///
- /// A barge-in arrives here, on the inbound stream, and not on
- /// . It is an event the caller caused, and the order between "the
- /// caller cut in" and "the caller then said this" is what a turn loop must never get wrong. One
- /// ordered stream keeps that ordering inside the adapter, which is the only place that has the
- /// information to settle it.
- ///
- ///
- /// This port always carries text, whatever the wire carries. A bundled transport such as
- /// the Telnyx Conversation Relay receives text frames and passes them straight through. A
- /// transport whose socket carries audio runs recognition inside its own adapter and emits text
- /// here. Audio stops at the adapter's edge and never enters this assembly, which is D28 and
- /// section 11 item 6c. That is also why this port is named for the conversation and not for speech:
- /// names the vendor behind each speech role — one vendor, or two —
- /// and this does not.
- ///
- ///
- public interface IConversationInputPort : IAsyncDisposable
- {
- /// Reads what the caller does, until the conversation ends.
- ///
- /// Abandons the read. Cancelling it throws, and never ends the stream quietly — see the remarks.
- ///
- /// Every inbound event of the conversation, in order.
- ///
- ///
- /// One consumer only. This method may not be called a second time on one instance, and
- /// a second call throws. is a factory rather than a stream,
- /// so nothing in the type itself stops two enumerators from being taken over one socket, and
- /// two consumers of one socket would each see half the conversation.
- ///
- ///
- /// The conversation ending and the read being cancelled are two different endings, and every
- /// implementation must tell them apart the same way. The conversation ending — the caller hung up,
- /// the transport went away, the vendor closed the socket — completes the stream normally, and
- /// that is the ordinary end of an await foreach over it. Cancelling
- /// throws instead,
- /// as any cancelled await does. A consumer therefore reads "the loop ended" as "the conversation is
- /// over" and needs no flag to ask which happened, and an implementation may not swallow its
- /// consumer's own cancellation into a clean ending: a test double that throws where the real
- /// port completes, or the reverse, is a difference the whole point of this port is that nobody
- /// above it can see.
- ///
- ///
- /// This port is already being read.
- /// was cancelled.
- IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default);
- }
-}
diff --git a/src/AgentCore.Application/Ports/IConversationOutputPort.cs b/src/AgentCore.Application/Ports/IConversationOutputPort.cs
deleted file mode 100644
index 4b1ffaaa..00000000
--- a/src/AgentCore.Application/Ports/IConversationOutputPort.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using AgentCore.Application.Conversation;
-
-namespace AgentCore.Application.Ports
-{
- ///
- /// What the agent says, for one conversation.
- ///
- ///
- ///
- /// One instance serves one conversation and is disposed with it, as is.
- ///
- ///
- /// Three methods and no events. Every studied voice framework gives its speaking side a method
- /// and never a callback the core subscribes to, and the rule is worth more in C# than in the
- /// languages they are written in: an event promises no ordering across threads.
- ///
- ///
- public interface IConversationOutputPort : IAsyncDisposable
- {
- /// Speaks one fragment of the reply.
- /// The text to speak. Never empty.
- /// Stops waiting for room to send.
- ///
- /// One fragment, never a buffered sentence. A synthesizer that paces its own audio starts
- /// speaking before the model finishes the reply, and buffering to a sentence would add the
- /// whole first sentence to the time before the caller hears anything.
- ///
- ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default);
-
- /// Closes one reply, so the synthesizer knows no more is coming.
- /// Stops waiting for room to send.
- ValueTask CompleteAsync(CancellationToken cancellationToken = default);
-
- /// Starts stopping the reply, and drops whatever has not been spoken.
- /// Stops waiting on the transport.
- ///
- /// This does not return the cut point, and the two are not simultaneous. The settled
- /// answer — what the caller heard, and for how long — arrives afterwards as a
- /// on the input stream. A vendor that reports the cut itself
- /// settles at once. One built from a separate synthesizer must wait for its own audio to
- /// finish draining first, and every implementation of that wait carries a deadline: two
- /// seconds is the budget, after which the reply counts as fully heard.
- ///
- ValueTask StopAsync(CancellationToken cancellationToken = default);
- }
-}
diff --git a/src/AgentCore.Application/Ports/IConversationPort.cs b/src/AgentCore.Application/Ports/IConversationPort.cs
index 5c0ab7a6..1e974d72 100644
--- a/src/AgentCore.Application/Ports/IConversationPort.cs
+++ b/src/AgentCore.Application/Ports/IConversationPort.cs
@@ -1,3 +1,4 @@
+using AgentCore.Application.Runtime;
using AgentCore.Domain;
using AgentCore.Domain.Knowledge;
using Microsoft.Extensions.AI;
@@ -30,7 +31,7 @@ public interface IConversationPort
/// Cancels the model calls.
/// The finished turn. It always carries a spoken line.
///
- /// The conversation already ended, or another turn of this conversation is still running.
+ /// The conversation already ended, or another turn of this conversation still ran after the wait limit.
///
Task RunTurnAsync(string userInput, CancellationToken cancellationToken = default);
@@ -39,7 +40,7 @@ public interface IConversationPort
/// Cancels the model calls.
/// The finished turn. Its reply is empty while approval requests are pending.
///
- /// The conversation already ended, or another turn of this conversation is still running.
+ /// The conversation already ended, or another turn of this conversation still ran after the wait limit.
///
Task RunTurnMessageAsync(ChatMessage userInput, CancellationToken cancellationToken);
@@ -53,7 +54,7 @@ public interface IConversationPort
/// 40-fragment reply, and an adapter must not have to filter them again.
///
///
- /// The conversation already ended, or another turn of this conversation is still running.
+ /// The conversation already ended, or another turn of this conversation still ran after the wait limit.
///
IAsyncEnumerable RunTurnStreamingAsync(
string userInput,
@@ -64,39 +65,50 @@ IAsyncEnumerable RunTurnStreamingAsync(
/// Cancels the model calls.
/// The reply, one update at a time. Every update carries content.
///
- /// The conversation already ended, or another turn of this conversation is still running.
+ /// The conversation already ended, or another turn of this conversation still ran after the wait limit.
///
IAsyncEnumerable RunTurnMessageStreamingAsync(
ChatMessage userInput,
CancellationToken cancellationToken);
- /// Ends the running turn where the caller cut the reply off.
- /// The text the caller actually heard.
- /// How much of the reply played, as the relay reported it.
- ///
- /// Whether the turn running now is the turn the caller was hearing. An adapter that paces no
- /// audio of its own leaves this at and lets the implementation decide.
+ /// Starts one turn, and hands back its index and its reply as it arrives.
+ /// What the caller said or answered: words, an approval answer, or both.
+ /// Where the turn hangs in the conversation the caller can see, or .
+ /// Cancels the turn, from the start through its last update.
+ ///
+ /// The started turn. reaches it by from now on. Enumerate
+ /// once: the turn runs and commits only then. Dispose the run, read or not:
+ /// the conversation is freed when the reply is read to the end or the run is disposed.
+ ///
+ ///
+ /// The conversation already ended, or another turn of this conversation still ran after the wait limit.
+ ///
+ Task StartTurnAsync(
+ ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default);
+
+ /// Cuts one turn's reply where the user stopped seeing or hearing it (design section 3, the Cut rule).
+ /// The turn the user was seeing or hearing.
+ ///
+ /// What reached the user. keeps everything the turn yielded.
///
///
- /// when the barge-in was recorded, either against the running turn or
- /// against the turn that finished last, and when there was nothing to
- /// record it against.
+ /// when the cut was recorded: a running turn stops and commits with it, and a committed
+ /// turn has its reply rewritten. when the turn is unknown or already cut, or when it is
+ /// older than the newest turn started on this conversation: once a later turn starts, an earlier turn's reply
+ /// never changes, even while the later turn is still running.
///
- ///
- ///
- /// Item 6a of section 11 asks the record to hold what the caller heard, not what the model
- /// produced. Section 7.1 reports both values on the interrupt frame, at 1 ms, so an
- /// adapter passes them through and never computes them. Item 6c forbids the estimator that would
- /// otherwise stand here.
- ///
- ///
- /// carries one domain fact and no frame schema, so D8 holds.
- /// A vendor that paces the audio itself is still speaking one turn while the next one already
- /// runs — a held prompt starts it inside the finished turn's own ending — and only that adapter
- /// can tell the two apart. It answers there, and the turn the caller was
- /// actually hearing is the one the record corrects.
- ///
- ///
- bool Interrupt(string utteranceUntilInterrupt, TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true);
+ /// is negative.
+ bool Cut(int turnIndex, TurnCut cut);
+
+ /// Replaces the cut a turn already took with a later account of what reached the user.
+ /// The turn the user was seeing or hearing.
+ /// What reached the user, as it is now known.
+ ///
+ /// when the recut was recorded: the turn commits with it, or its committed reply is
+ /// rewritten to it. when the turn is unknown or took no cut, or when it is older than
+ /// the newest turn started on this conversation, under the same rule as .
+ ///
+ /// is negative.
+ bool Recut(int turnIndex, TurnCut cut);
}
}
diff --git a/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs b/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs
index 8b5d2e00..1c0c8102 100644
--- a/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs
+++ b/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs
@@ -1,17 +1,14 @@
namespace AgentCore.Application.Ports
{
///
- /// Finds the session store of one entry.
+ /// Finds the one session owner for the whole app, and the entries it serves.
///
public interface IConversationSessionRegistry
{
/// Gets the names of the entries the document declares.
IReadOnlyCollection Entries { get; }
- /// Reads one entry's session store.
- /// The entry key, as the document declares it.
- /// The store for that entry.
- /// The entry is not declared.
- IConversationSessions ForSessions(string entry);
+ /// Gets the one session owner over every entry.
+ IConversationSessions Sessions { get; }
}
}
diff --git a/src/AgentCore.Application/Ports/IConversationSessions.cs b/src/AgentCore.Application/Ports/IConversationSessions.cs
index 950b6798..adc2032e 100644
--- a/src/AgentCore.Application/Ports/IConversationSessions.cs
+++ b/src/AgentCore.Application/Ports/IConversationSessions.cs
@@ -1,47 +1,64 @@
+using AgentCore.Application.Conversation;
using AgentCore.Application.Runtime;
namespace AgentCore.Application.Ports
{
///
- /// Owns the session of a conversation for as long as the conversation needs it.
+ /// Owns the session of a conversation for as long as the conversation needs it, for every entry the
+ /// document declares. One conversation id has at most one live session, under one entry.
///
- ///
- /// A conversation runs over many turns and one request carries one turn, so something has to hold the
- /// session in between. This is that seam, and it owns the whole life of the session rather than
- /// only the holding: a caller asks for the session of a conversation and never builds one itself.
- ///
public interface IConversationSessions
{
- /// Opens the session of one conversation.
+ ///
+ /// Returns the live session of one conversation under one entry, or opens it when none is held.
+ ///
+ /// The entry the session is held under, or opens under.
/// The id the host gives the conversation, or to be given one.
- /// Cancels the open.
- /// The session, ready for its first turn.
- ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default);
+ ///
+ /// The stored state to build the session from. It is used only when this call builds the session; a live
+ /// session keeps its own state, and this is ignored.
+ ///
+ ///
+ /// Cancels this caller's wait for a build or a close another caller started. That build or close runs on.
+ ///
+ ///
+ /// The session, ready for its next turn. Two callers that ask for the same id under the same entry at once
+ /// get the same session, built once. When the id's session is closing, this waits until its teardown is
+ /// done (its last words flushed, its shells stopped, its workspace folder deleted), then opens a new one.
+ ///
+ ///
+ /// is held, or being opened, under another entry. Nothing was built, and
+ /// that entry's session is left running, untouched. Once that session is gone (closed or unloaded), this
+ /// entry may open the id and read its history.
+ ///
+ ValueTask GetOrOpenAsync(
+ string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default);
- /// Reads the session of one conversation, and marks the conversation as still live.
+ /// Reads the session of one conversation under one entry, and marks the conversation as still live.
+ /// The entry the session must be held under.
/// The id the request named. It is the .
/// Cancels the read.
- /// The session, or when this holds no such conversation.
- ///
- /// A read is the only signal an implementation gets that a conversation is still being had, so an
- /// implementation that expires an idle session must restart that session's clock here. A caller
- /// that already holds the session for the whole conversation — the relay socket does — must therefore
- /// still read it back on each turn, or its own conversation is the one that expires.
- ///
- ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default);
+ ///
+ /// The session, or when this holds no usable session of that id under this entry:
+ /// none is held, it is held under another entry, or it is still being built or already closing. A lookup
+ /// never waits and never closes anything.
+ ///
+ ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default);
- /// Ends one conversation: waits for the words it still owes store 1, then drops it.
- /// The id of the conversation that ended.
- /// Cancels the close.
- /// A task that completes once the session is gone and its writes are done.
- ///
- /// The order is the contract. A turn queues its rows and speaks, so a conversation can end with its last
- /// turn still in flight, and the session is the only thing that can wait for those writes — once
- /// it is dropped nothing can, and the durable record loses the turn the caller just had with no
- /// error anywhere to say so. Every way a session ends comes through here, expiry included, so
- /// no path can be written that skips the wait. An implementation must also dispose the session:
- /// it owns processes (a conversation's shell: executors) that a dropped reference would leak.
- ///
- ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default);
+ ///
+ /// Closes one conversation's session: waits for the words it still owes store 1, disposes its background
+ /// children and shells, then deletes its workspace folder. This writes no conversation.ended
+ /// event; a caller that wants the close to be a real end calls
+ /// itself, before this. The id stays taken until the
+ /// teardown is done, so an open of it waits rather than race the folder delete.
+ ///
+ /// The entry the session must be held under.
+ /// The id of the conversation to close.
+ /// Cancels only a wait for a close another caller already started.
+ ///
+ /// A task that completes once the session is gone and its writes are done. When this holds no live session
+ /// of that id under this entry, nothing is closed.
+ ///
+ ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default);
}
}
diff --git a/src/AgentCore.Application/Ports/IConversationStore.cs b/src/AgentCore.Application/Ports/IConversationStore.cs
index 3ea1049a..d9373409 100644
--- a/src/AgentCore.Application/Ports/IConversationStore.cs
+++ b/src/AgentCore.Application/Ports/IConversationStore.cs
@@ -71,21 +71,21 @@ ValueTask ListAsync(
/// alone. It rides with the words on purpose: a crash between the two would leave the stage
/// behind the words it belongs to. For the same reason, a non- state is
/// silently dropped when is empty: an empty turn writes no words for
- /// it to ride with, so there is nothing to write it beside.
+ /// it to ride with, so there is nothing to write it beside. It is also dropped, while the words
+ /// land, when its is behind the stored state's:
+ /// a state never replaces a newer one.
///
/// Cancels the write.
///
/// The rows as written, in the order given, each with its ordinal and its turn index.
///
- ///
- /// The store numbers every row from the conversation's own counter, in one atomic step, so two writers on
- /// two machines never collide on an ordinal — one from the conversation's live session, the other from a
- /// host appending outside any turn.
- ///
///
/// The conversation named by does not exist. A row is never written against a
/// conversation that has no row of its own.
///
+ ///
+ /// A message names a turn the conversation already saved. Nothing of the append is written: no row, and no state.
+ ///
ValueTask> AppendAsync(
string conversationId,
IReadOnlyList messages,
@@ -128,6 +128,18 @@ ValueTask RewriteAsync(
ChatMessage content,
CancellationToken cancellationToken = default);
+ ///
+ /// Deletes one already-written message, on a barge-in that leaves it saying and doing nothing. The rows
+ /// around it keep their ordinals.
+ ///
+ /// The conversation the message belongs to.
+ /// The message to delete. A message the conversation does not hold is no error.
+ /// Cancels the delete.
+ ValueTask DeleteMessageAsync(
+ string conversationId,
+ string messageId,
+ CancellationToken cancellationToken = default);
+
///
/// Reads a conversation's words as its session opens them: the newest summary row, and every row
/// above what it covers. The rows a summary stands in for are not read. With no summary, every row.
@@ -181,23 +193,54 @@ ValueTask TruncateAsync(
ValueTask EraseAsync(string conversationId, CancellationToken cancellationToken = default);
///
- /// Deletes every continuation row untouched past the retention window.
+ /// Deletes every response id written past the retention window.
///
///
- /// How long a continuation row is kept, measured from when it was last written. The window belongs
- /// to a deployment: it is not a schema key, and nothing here defaults it.
+ /// How long a response id is kept, measured from when it was written. The window belongs to a
+ /// deployment: it is not a schema key, and nothing here defaults it.
///
///
- /// How many continuation rows one transaction may delete. The sweep loops until a batch deletes
+ /// How many response ids one transaction may delete. The sweep loops until a batch deletes
/// nothing, so this bounds one transaction and never the work.
///
/// Cancels the sweep between batches, and inside one.
- /// How many continuation rows went, over every batch.
+ /// How many response ids went, over every batch.
ValueTask SweepAsync(
TimeSpan retention,
int batchSize = 500,
CancellationToken cancellationToken = default);
+ ///
+ /// Puts a holder's busy mark on a conversation, or extends the mark the holder already has, unless another
+ /// holder's mark is still live.
+ ///
+ ///
+ /// The conversation to mark. Its row need not exist: the first turn of a conversation marks it before making it.
+ ///
+ /// An opaque id of whoever holds the mark, the same on every renewal.
+ /// How long the mark lives from now, on the store's own clock.
+ /// Cancels the write.
+ ///
+ /// when now holds the mark; when
+ /// another holder's mark is still live, which is then left as it is.
+ ///
+ ValueTask TryMarkBusyAsync(
+ string conversationId,
+ string holder,
+ TimeSpan lease,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Takes a holder's busy mark off a conversation. A mark another holder has put since is left as it is.
+ ///
+ /// The conversation to clear.
+ /// The id the mark was put under.
+ /// Cancels the write.
+ ValueTask ClearBusyAsync(
+ string conversationId,
+ string holder,
+ CancellationToken cancellationToken = default);
+
/// Gives a principal a claim on a conversation.
/// The conversation to claim.
/// The opaque key that claims it.
@@ -218,34 +261,30 @@ ValueTask DetachPrincipalAsync(
string principalKey,
CancellationToken cancellationToken = default);
- /// Files one session envelope under one continuation id, replacing any envelope already there.
- /// The continuation id: a conversation id or a response id.
- /// The conversation the continuation belongs to.
- /// The serialized session, as the agent wrote it.
+ ///
+ /// Records that one response id continues one conversation.
+ ///
+ /// The response id to record.
+ /// The conversation the response id continues.
/// Cancels the write.
- ///
- /// The continuation names a conversation whose words live in store 1, so the map lives in this store:
- /// a resume must never find the key without the words.
- ///
ValueTask SaveContinuationAsync(
- string continuationId,
+ string responseId,
string conversationId,
- JsonElement envelope,
CancellationToken cancellationToken = default);
- /// Reads the envelope one continuation id names.
- /// The continuation id to look up.
+ /// Finds the conversation one response id continues.
+ /// The response id to look up.
/// Cancels the read.
- /// The envelope, or when nothing is filed under that id.
- ValueTask GetContinuationAsync(
- string continuationId,
+ /// The conversation id, or when no row names that response id.
+ ValueTask FindContinuationAsync(
+ string responseId,
CancellationToken cancellationToken = default);
- /// Withdraws whatever one continuation id names, if anything.
- /// The continuation id to forget.
+ /// Withdraws one response id, if it names a row.
+ /// The response id to forget.
/// Cancels the delete.
ValueTask DeleteContinuationAsync(
- string continuationId,
+ string responseId,
CancellationToken cancellationToken = default);
}
}
diff --git a/src/AgentCore.Application/Ports/ISpeechAdapter.cs b/src/AgentCore.Application/Ports/ISpeechAdapter.cs
deleted file mode 100644
index 39563412..00000000
--- a/src/AgentCore.Application/Ports/ISpeechAdapter.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-namespace AgentCore.Application.Ports
-{
- ///
- /// Names the vendor behind one speech role's kind, stt or tts.
- ///
- ///
- ///
- /// This is the speech mirror of , ,
- /// , and , and it takes the same shape:
- /// the host lists the vendors it supports once, providers.speech names which one the
- /// document means for each role — stt is recognition and tts is synthesis — and a
- /// document that changes vendors changes no code.
- ///
- ///
- /// It declares no member of its own, so it carries a and
- /// nothing else, on purpose. D15 makes every public member
- /// a permanent obligation, so a member is added when a caller exists for it and not before. The only
- /// vendor this solution ships is a bundled transport — the Telnyx Conversation Relay — and a relay
- /// conversation is born from a socket the host already accepted: there is nothing for the adapter to create at
- /// selection time, because the frames arriving on that socket are the channel. A create method here
- /// today would be a signature no caller passes through and every future vendor must still honour. The
- /// first split vendor, whose speech is a separate service the process must dial, is the caller that
- /// earns the creation surface, and it adds it then.
- ///
- ///
- /// So each role's kind is a naming field, not a selecting one: it answers "which
- /// vendor did the document mean", and nothing here turns that answer into a constructed adapter.
- /// is what reads the names today, and only to check them
- /// against providers.conversation.kind — because a transport whose frames already carry text, such as
- /// the relay this solution ships, is itself the recognizer and the synthesizer, so once the names
- /// agree there is nothing left to build. stays the seam's
- /// per-conversation face: it answers "open both halves of this one conversation", opened once for every conversation
- /// rather than once while the host starts.
- ///
- ///
- /// providers.conversation and providers.speech are required of one another. The schema
- /// writes required: ["conversation", "speech"] on the providers object, so a document that
- /// writes a providers section at all writes both, and AddAgentCoreAsync refuses a
- /// configuration that registered a conversation transport and named no speech vendor. A transport whose
- /// frames already carry text must further be named by both speech roles, because such a
- /// vendor performs recognition and synthesis itself and a document naming it for the pipe and
- /// something else for the ears or the mouth describes a deployment that cannot exist.
- /// is what enforces that, while the host starts and before any
- /// route is mapped, because the agreement is a fact about the document rather than about a route.
- ///
- ///
- public interface ISpeechAdapter : IVendorAdapter
- {
- }
-}
diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt
index 357955ed..0321c888 100644
--- a/src/AgentCore.Application/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt
@@ -10,27 +10,8 @@ AgentCore.Application.Audit.QueuedAuditSink.AppendAsync(AgentCore.Domain.Audit.A
AgentCore.Application.Audit.QueuedAuditSink.Dispose() -> void
AgentCore.Application.Audit.QueuedAuditSink.DisposeAsync() -> System.Threading.Tasks.ValueTask
AgentCore.Application.Audit.QueuedAuditSink.FlushAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Audit.QueuedAuditSink.QueuedAuditSink(AgentCore.Application.Ports.IAuditSinkPort! inner, Microsoft.Extensions.Logging.ILogger? logger = null, int capacity = 10000, int batchSize = 100) -> void
+AgentCore.Application.Audit.QueuedAuditSink.QueuedAuditSink(AgentCore.Application.Ports.IAuditSinkPort! inner, Microsoft.Extensions.Logging.ILogger? logger = null, int capacity = 10000, int batchSize = 100, System.TimeProvider? timeProvider = null) -> void
AgentCore.Application.Audit.QueuedAuditSink.Store.get -> AgentCore.Application.Ports.IAuditSinkPort!
-AgentCore.Application.Conversation.ConversationChannel
-AgentCore.Application.Conversation.ConversationChannel.$() -> AgentCore.Application.Conversation.ConversationChannel!
-AgentCore.Application.Conversation.ConversationChannel.ConversationChannel(AgentCore.Application.Ports.IConversationInputPort! Input, AgentCore.Application.Ports.IConversationOutputPort! Output) -> void
-AgentCore.Application.Conversation.ConversationChannel.Deconstruct(out AgentCore.Application.Ports.IConversationInputPort! Input, out AgentCore.Application.Ports.IConversationOutputPort! Output) -> void
-AgentCore.Application.Conversation.ConversationChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Conversation.ConversationChannel.Equals(AgentCore.Application.Conversation.ConversationChannel? other) -> bool
-AgentCore.Application.Conversation.ConversationChannel.Input.get -> AgentCore.Application.Ports.IConversationInputPort!
-AgentCore.Application.Conversation.ConversationChannel.Input.init -> void
-AgentCore.Application.Conversation.ConversationChannel.Output.get -> AgentCore.Application.Ports.IConversationOutputPort!
-AgentCore.Application.Conversation.ConversationChannel.Output.init -> void
-AgentCore.Application.Conversation.ConversationChannelContext
-AgentCore.Application.Conversation.ConversationChannelContext.$() -> AgentCore.Application.Conversation.ConversationChannelContext!
-AgentCore.Application.Conversation.ConversationChannelContext.ConversationChannelContext(string! ConversationId, System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void
-AgentCore.Application.Conversation.ConversationChannelContext.ConversationId.get -> string!
-AgentCore.Application.Conversation.ConversationChannelContext.ConversationId.init -> void
-AgentCore.Application.Conversation.ConversationChannelContext.CustomParameters.get -> System.Collections.Generic.IReadOnlyDictionary?
-AgentCore.Application.Conversation.ConversationChannelContext.CustomParameters.init -> void
-AgentCore.Application.Conversation.ConversationChannelContext.Deconstruct(out string! ConversationId, out System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void
-AgentCore.Application.Conversation.ConversationChannelContext.Equals(AgentCore.Application.Conversation.ConversationChannelContext? other) -> bool
AgentCore.Application.Conversation.ConversationClarificationState
AgentCore.Application.Conversation.ConversationClarificationState.$() -> AgentCore.Application.Conversation.ConversationClarificationState!
AgentCore.Application.Conversation.ConversationClarificationState.ConversationClarificationState() -> void
@@ -38,32 +19,6 @@ AgentCore.Application.Conversation.ConversationClarificationState.Equals(AgentCo
AgentCore.Application.Conversation.ConversationClarificationState.ProbeAsks.get -> int
AgentCore.Application.Conversation.ConversationClarificationState.ProbeAsks.init -> void
AgentCore.Application.Conversation.ConversationCursor
-AgentCore.Application.Conversation.ConversationInput
-AgentCore.Application.Conversation.ConversationInput.Barge
-AgentCore.Application.Conversation.ConversationInput.Barge.Barge(string! HeardText, System.TimeSpan PlayedDuration) -> void
-AgentCore.Application.Conversation.ConversationInput.Barge.Deconstruct(out string! HeardText, out System.TimeSpan PlayedDuration) -> void
-AgentCore.Application.Conversation.ConversationInput.Barge.Equals(AgentCore.Application.Conversation.ConversationInput.Barge? other) -> bool
-AgentCore.Application.Conversation.ConversationInput.Barge.HeardText.get -> string!
-AgentCore.Application.Conversation.ConversationInput.Barge.HeardText.init -> void
-AgentCore.Application.Conversation.ConversationInput.Barge.PlayedDuration.get -> System.TimeSpan
-AgentCore.Application.Conversation.ConversationInput.Barge.PlayedDuration.init -> void
-AgentCore.Application.Conversation.ConversationInput.ConversationInput(AgentCore.Application.Conversation.ConversationInput! original) -> void
-AgentCore.Application.Conversation.ConversationInput.Keypress
-AgentCore.Application.Conversation.ConversationInput.Keypress.Deconstruct(out string! Key) -> void
-AgentCore.Application.Conversation.ConversationInput.Keypress.Equals(AgentCore.Application.Conversation.ConversationInput.Keypress? other) -> bool
-AgentCore.Application.Conversation.ConversationInput.Keypress.Key.get -> string!
-AgentCore.Application.Conversation.ConversationInput.Keypress.Key.init -> void
-AgentCore.Application.Conversation.ConversationInput.Keypress.Keypress(string! Key) -> void
-AgentCore.Application.Conversation.ConversationInput.Utterance
-AgentCore.Application.Conversation.ConversationInput.Utterance.Deconstruct(out string! Text, out string! Language, out bool IsFinal) -> void
-AgentCore.Application.Conversation.ConversationInput.Utterance.Equals(AgentCore.Application.Conversation.ConversationInput.Utterance? other) -> bool
-AgentCore.Application.Conversation.ConversationInput.Utterance.IsFinal.get -> bool
-AgentCore.Application.Conversation.ConversationInput.Utterance.IsFinal.init -> void
-AgentCore.Application.Conversation.ConversationInput.Utterance.Language.get -> string!
-AgentCore.Application.Conversation.ConversationInput.Utterance.Language.init -> void
-AgentCore.Application.Conversation.ConversationInput.Utterance.Text.get -> string!
-AgentCore.Application.Conversation.ConversationInput.Utterance.Text.init -> void
-AgentCore.Application.Conversation.ConversationInput.Utterance.Utterance(string! Text, string! Language, bool IsFinal) -> void
AgentCore.Application.Conversation.ConversationPage
AgentCore.Application.Conversation.ConversationPage.$() -> AgentCore.Application.Conversation.ConversationPage!
AgentCore.Application.Conversation.ConversationPage.ConversationPage(System.Collections.Generic.IReadOnlyList! Conversations, string? NextCursor) -> void
@@ -96,6 +51,17 @@ AgentCore.Application.Conversation.ConversationRecord.Status.get -> AgentCore.Ap
AgentCore.Application.Conversation.ConversationRecord.Status.init -> void
AgentCore.Application.Conversation.ConversationRecord.Title.get -> string?
AgentCore.Application.Conversation.ConversationRecord.Title.init -> void
+AgentCore.Application.Conversation.ConversationInUseException
+AgentCore.Application.Conversation.ConversationInUseException.ConversationId.get -> string?
+AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException() -> void
+AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! conversationId, string! holdingEntry, string! refusedEntry) -> void
+AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! message) -> void
+AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! message, System.Exception? innerException) -> void
+AgentCore.Application.Conversation.ConversationInUseException.HoldingEntry.get -> string?
+AgentCore.Application.Conversation.ConversationTurnConflictException
+AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException() -> void
+AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException(string! message) -> void
+AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException(string! message, System.Exception? innerException) -> void
AgentCore.Application.Conversation.ConversationSessionState
AgentCore.Application.Conversation.ConversationSessionState.$() -> AgentCore.Application.Conversation.ConversationSessionState!
AgentCore.Application.Conversation.ConversationSessionState.ConversationSessionState() -> void
@@ -116,7 +82,6 @@ AgentCore.Application.Conversation.ConversationSessionState.Version.get -> int
AgentCore.Application.Conversation.ConversationSessionState.Version.init -> void
AgentCore.Application.Conversation.ConversationSessionState.WorkflowState.get -> System.Text.Json.JsonElement?
AgentCore.Application.Conversation.ConversationSessionState.WorkflowState.init -> void
-AgentCore.Application.Conversation.ConversationSpeechPairing
AgentCore.Application.Conversation.ConversationStateJson
AgentCore.Application.Conversation.ConversationStatus
AgentCore.Application.Conversation.ConversationStatus.Archived = 1 -> AgentCore.Application.Conversation.ConversationStatus
@@ -131,16 +96,16 @@ AgentCore.Application.Conversation.Memory.InMemoryConversationStore.AppendAsync(
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.CreateAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DetachPrincipalAsync(string! conversationId, string! principalKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Conversation.Memory.InMemoryConversationStore.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Conversation.Memory.InMemoryConversationStore.GetContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.InMemoryConversationStore(System.TimeProvider? timeProvider = null) -> void
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SaveContinuationAsync(string! continuationId, string! conversationId, System.Text.Json.JsonElement envelope, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetCustomAsync(string! conversationId, System.Text.Json.JsonElement? custom, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetStatusAsync(string! conversationId, AgentCore.Application.Conversation.ConversationStatus status, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
@@ -232,6 +197,7 @@ AgentCore.Application.Configuration.Compilation.ResolvedKnowledge.Scoped.init ->
AgentCore.Application.Configuration.Parsing.ConfigurationCheck
AgentCore.Application.Configuration.Parsing.ConfigurationCheck.DelegationCycles = 8 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
AgentCore.Application.Configuration.Parsing.ConfigurationCheck.DocumentSchema = 1 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
+AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GraphNodeBackground = 10 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GraphWellFormedness = 7 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GuardExclusivity = 5 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GuardOperators = 4 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck
@@ -1041,42 +1007,32 @@ AgentCore.Application.Ports.IAuditSinkAdapter.OpenAsync(AgentCore.Application.Co
AgentCore.Application.Ports.IAuditSinkPort
AgentCore.Application.Ports.IAuditSinkPort.AppendAsync(AgentCore.Domain.Audit.AuditEvent! auditEvent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IAuditSinkPort.AppendManyAsync(System.Collections.Generic.IReadOnlyList! auditEvents, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationAdapter
-AgentCore.Application.Ports.IConversationAdapter.CarriesText.get -> bool
-AgentCore.Application.Ports.IConversationChannelFactory
-AgentCore.Application.Ports.IConversationChannelFactory.OpenAsync(AgentCore.Application.Conversation.ConversationChannelContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationInputPort
-AgentCore.Application.Ports.IConversationInputPort.ListenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
AgentCore.Application.Ports.IConversationObserver
AgentCore.Application.Ports.IConversationObserver.OnConversationEventAsync(AgentCore.Application.Runtime.ConversationEvent! conversationEvent, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationOutputPort
-AgentCore.Application.Ports.IConversationOutputPort.CompleteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationOutputPort.SpeakAsync(string! fragment, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationOutputPort.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationSessionFactory
AgentCore.Application.Ports.IConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.ConversationSession!
AgentCore.Application.Ports.IConversationSessionRegistry
AgentCore.Application.Ports.IConversationSessionRegistry.Entries.get -> System.Collections.Generic.IReadOnlyCollection!
-AgentCore.Application.Ports.IConversationSessionRegistry.ForSessions(string! entry) -> AgentCore.Application.Ports.IConversationSessions!
+AgentCore.Application.Ports.IConversationSessionRegistry.Sessions.get -> AgentCore.Application.Ports.IConversationSessions!
AgentCore.Application.Ports.IConversationSessions
-AgentCore.Application.Ports.IConversationSessions.CloseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationSessions.OpenAsync(string? conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationSessions.TryGetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationSessions.CloseAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationSessions.GetOrOpenAsync(string! entry, string? conversationId, AgentCore.Application.Conversation.ConversationSessionState? state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationSessions.TryGetAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore
AgentCore.Application.Ports.IConversationStore.AppendAsync(string! conversationId, System.Collections.Generic.IReadOnlyList! messages, AgentCore.Application.Conversation.ConversationSessionState? state = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
AgentCore.Application.Ports.IConversationStore.AppendMessageAsync(string! conversationId, Microsoft.Extensions.AI.ChatMessage! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.CreateAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.DeleteAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationStore.DeleteContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationStore.DeleteContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.DetachPrincipalAsync(string! conversationId, string! principalKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationStore.GetContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationStore.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Ports.IConversationStore.SaveContinuationAsync(string! continuationId, string! conversationId, System.Text.Json.JsonElement envelope, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Ports.IConversationStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversations.SweepAsync(System.TimeSpan retention, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.SetCustomAsync(string! conversationId, System.Text.Json.JsonElement? custom, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.IConversationStore.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
@@ -1096,7 +1052,6 @@ AgentCore.Application.Ports.IChatClientFactory.GetContextWindow(AgentCore.Applic
AgentCore.Application.Ports.IChatClientFactory.ResolveHostedTool(Microsoft.Extensions.AI.AITool! marker, AgentCore.Application.Configuration.Schema.ModelReference? model) -> Microsoft.Extensions.AI.AITool?
AgentCore.Application.Ports.IConversationPort
AgentCore.Application.Ports.IConversationPort.ConversationId.get -> string!
-AgentCore.Application.Ports.IConversationPort.Interrupt(string! utteranceUntilInterrupt, System.TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true) -> bool
AgentCore.Application.Ports.IConversationPort.IsComplete.get -> bool
AgentCore.Application.Ports.IConversationPort.LastTurn.get -> AgentCore.Domain.TurnResult?
AgentCore.Application.Ports.IConversationPort.RunTurnAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
@@ -1106,6 +1061,8 @@ AgentCore.Application.Ports.IConversationPort.RunTurnStreamingAsync(string! user
AgentCore.Application.Ports.IConversationPort.Scope.get -> AgentCore.Domain.Knowledge.KnowledgeScope?
AgentCore.Application.Ports.IConversationPort.Scope.set -> void
AgentCore.Application.Ports.IConversationPort.Stage.get -> string!
+AgentCore.Application.Ports.IConversationPort.Cut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool
+AgentCore.Application.Ports.IConversationPort.Recut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool
AgentCore.Application.Ports.IEmbeddingGeneratorAdapter
AgentCore.Application.Ports.IEmbeddingGeneratorAdapter.CreateGeneratorAsync(AgentCore.Application.Configuration.Schema.EmbeddingProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>!>
AgentCore.Application.Ports.IEvaluationScorePublisher
@@ -1125,7 +1082,6 @@ AgentCore.Application.Ports.ISecretResolverPort
AgentCore.Application.Ports.ISecretResolverPort.TryResolveAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.ISourcePort
AgentCore.Application.Ports.ISourcePort.Publish(AgentCore.Domain.Sources.SourceReference! source) -> void
-AgentCore.Application.Ports.ISpeechAdapter
AgentCore.Application.Ports.ITelemetryAdapter
AgentCore.Application.Ports.ITelemetryAdapter.StartAsync(AgentCore.Application.Configuration.Schema.TelemetryProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Ports.ITelemetrySession
@@ -1150,8 +1106,8 @@ AgentCore.Application.Providers.VendorSeam.VendorSeam() -> void
AgentCore.Application.Providers.VendorSeam.VendorSeam(string! DocumentPath, string! Pointer, string! RegistrationHint, string! Plural) -> void
AgentCore.Application.Providers.VendorSeam.VendorSeam(string! documentPath, string! pointer, string! registrationHint) -> void
AgentCore.Application.Runtime.AgentCoreAgent
-AgentCore.Application.Runtime.AgentCoreAgent.AgentCoreAgent(AgentCore.Application.Ports.IConversationSessionFactory! sessions, string! entryName, string? description = null) -> void
-AgentCore.Application.Runtime.AgentCoreAgent.CreateSessionAsync(string! conversationId) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Runtime.AgentCoreAgent.AgentCoreAgent(AgentCore.Application.Ports.IConversationSessions! sessions, string! entryName, string? description = null) -> void
+AgentCore.Application.Runtime.AgentCoreAgent.CreateSessionAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Runtime.AgentCoreAgent.EntryName.get -> string!
AgentCore.Application.Runtime.ConversationEvent
AgentCore.Application.Runtime.ConversationEvent.$() -> AgentCore.Application.Runtime.ConversationEvent!
@@ -1186,6 +1142,8 @@ AgentCore.Application.Runtime.ConversationEventKind.TranscriptResyncFailed = 13
AgentCore.Application.Runtime.ConversationEventKind.TranscriptWriteFailed = 10 -> AgentCore.Application.Runtime.ConversationEventKind
AgentCore.Application.Runtime.ConversationEventKind.TurnCompleted = 7 -> AgentCore.Application.Runtime.ConversationEventKind
AgentCore.Application.Runtime.ConversationEventKind.TurnSuperseded = 12 -> AgentCore.Application.Runtime.ConversationEventKind
+AgentCore.Application.Runtime.ConversationEventKind.TurnRefused = 14 -> AgentCore.Application.Runtime.ConversationEventKind
+AgentCore.Application.Runtime.ConversationEventKind.RunFaulted = 15 -> AgentCore.Application.Runtime.ConversationEventKind
AgentCore.Application.Runtime.ConversationObservers
AgentCore.Application.Runtime.ConversationSession
AgentCore.Application.Runtime.ConversationSession.ConversationId.get -> string!
@@ -1193,7 +1151,6 @@ AgentCore.Application.Runtime.ConversationSession.Compiled.get -> AgentCore.Appl
AgentCore.Application.Runtime.ConversationSession.DisposeAsync() -> System.Threading.Tasks.ValueTask
AgentCore.Application.Runtime.ConversationSession.EndConversation(AgentCore.Domain.Audit.ConversationEndReason reason) -> bool
AgentCore.Application.Runtime.ConversationSession.FlushTranscriptAsync() -> System.Threading.Tasks.Task!
-AgentCore.Application.Runtime.ConversationSession.Interrupt(string! utteranceUntilInterrupt, System.TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true) -> bool
AgentCore.Application.Runtime.ConversationSession.IsComplete.get -> bool
AgentCore.Application.Runtime.ConversationSession.LastReplyMessageId.get -> string?
AgentCore.Application.Runtime.ConversationSession.LastTurn.get -> AgentCore.Domain.TurnResult?
@@ -1214,6 +1171,8 @@ AgentCore.Application.Runtime.ConversationSession.Transcript.get -> System.Colle
AgentCore.Application.Runtime.ConversationSession.TryCreateApprovalAnswer(string! requestId, bool approved) -> Microsoft.Extensions.AI.ChatMessage?
AgentCore.Application.Runtime.ConversationSession.TryCreateApprovalAnswerAsync(string! requestId, bool approved, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Runtime.ConversationSession.Workspace.get -> string?
+AgentCore.Application.Runtime.ConversationSession.Cut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool
+AgentCore.Application.Runtime.ConversationSession.Recut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool
AgentCore.Application.Runtime.ConversationSessionFactory
AgentCore.Application.Runtime.ConversationSessionFactory.ConversationSessionFactory(AgentCore.Application.Configuration.Compilation.CompiledAgent! compiled, AgentCore.Application.Configuration.Validation.IGuardEvaluator! guards, AgentCore.Application.State.StateExtractor? extractor = null, System.TimeProvider? timeProvider = null, Microsoft.Extensions.Logging.ILogger? logger = null, System.Collections.Generic.IEnumerable? observers = null, string? workspaceRoot = null) -> void
AgentCore.Application.Runtime.ConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.ConversationSession!
@@ -1228,6 +1187,15 @@ AgentCore.Application.Runtime.ConversationTurnOrigin.NamesParent.get -> bool
AgentCore.Application.Runtime.ConversationTurnOrigin.NamesParent.init -> void
AgentCore.Application.Runtime.ConversationTurnOrigin.ParentMessageId.get -> string?
AgentCore.Application.Runtime.ConversationTurnOrigin.ParentMessageId.init -> void
+AgentCore.Application.Runtime.TurnCut
+AgentCore.Application.Runtime.TurnCut.$() -> AgentCore.Application.Runtime.TurnCut!
+AgentCore.Application.Runtime.TurnCut.Deconstruct(out string? ShownText, out System.TimeSpan? Played) -> void
+AgentCore.Application.Runtime.TurnCut.Equals(AgentCore.Application.Runtime.TurnCut? other) -> bool
+AgentCore.Application.Runtime.TurnCut.Played.get -> System.TimeSpan?
+AgentCore.Application.Runtime.TurnCut.Played.init -> void
+AgentCore.Application.Runtime.TurnCut.ShownText.get -> string?
+AgentCore.Application.Runtime.TurnCut.ShownText.init -> void
+AgentCore.Application.Runtime.TurnCut.TurnCut(string? ShownText, System.TimeSpan? Played) -> void
AgentCore.Application.Scripting.ScriptRequest
AgentCore.Application.Scripting.ScriptRequest.$() -> AgentCore.Application.Scripting.ScriptRequest!
AgentCore.Application.Scripting.ScriptRequest.Code.get -> string!
@@ -1267,12 +1235,12 @@ AgentCore.Application.Secrets.SecretResolutionException.SecretResolutionExceptio
AgentCore.Application.Secrets.SecretResolutionException.SecretResolutionException(string! message, System.Exception? innerException) -> void
AgentCore.Application.Secrets.SecretResolverExtensions
AgentCore.Application.Sessions.Memory.InMemoryConversationSessions
-AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.CloseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.CloseAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.Count.get -> int
-AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.InMemoryConversationSessions(AgentCore.Application.Ports.IConversationSessionFactory! factory, System.TimeSpan idleTimeout, System.TimeProvider! timeProvider) -> void
-AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.OpenAsync(string? conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.SweepAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.TryGetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.Dispose() -> void
+AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.InMemoryConversationSessions(System.Collections.Generic.IReadOnlyDictionary! factories, System.TimeSpan idleTimeout, System.TimeProvider! timeProvider) -> void
+AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.GetOrOpenAsync(string! entry, string? conversationId, AgentCore.Application.Conversation.ConversationSessionState? state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.TryGetAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
AgentCore.Application.Skills.SkillCatalog
AgentCore.Application.Skills.SkillCatalog.Names.get -> System.Collections.Generic.IReadOnlySet!
AgentCore.Application.Skills.SkillCatalog.SkillCatalog(Microsoft.Agents.AI.AgentSkillsSource! source, System.Collections.Generic.IReadOnlySet! names) -> void
@@ -1440,7 +1408,6 @@ AgentCore.Application.Transcript.SourceContent.Source.get -> AgentCore.Domain.So
AgentCore.Application.Transcript.SourceContent.Source.set -> void
AgentCore.Application.Transcript.SourceContent.SourceContent() -> void
AgentCore.Application.Transcript.TranscriptJson
-abstract AgentCore.Application.Conversation.ConversationInput.$() -> AgentCore.Application.Conversation.ConversationInput!
abstract AgentCore.Application.Tools.DeclaredTool.CallAsync(Microsoft.Extensions.AI.AIFunctionArguments! arguments, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
internal sealed class AuditingFunctionInvokingChatClient : FunctionInvokingChatClient
{
- // Drain state per open tool call, keyed by the model's own call id. The round that ends a
- // turn never reaches CreateResponseMessages, so a final round's entries are swept at turn
- // end through Release; every other entry is consumed where it drains.
private readonly ConcurrentDictionary _drains = new(StringComparer.Ordinal);
private sealed record Drain(
@@ -80,11 +77,6 @@ internal void Release(IReadOnlyList callIds)
string callId = context.CallContent.CallId;
- // The turn arrives on the run's own options — filed there by the loop for the outer
- // run and by the delegating bridge for a nested one, one value per run, so concurrent
- // runs never share it. A call with no turn runs bare, exactly as the null branch did
- // before. Nested runs arrive already stripped per K42 with the outermost call id kept,
- // so nothing here recomputes either.
TurnInvocation? invocation = null;
bool nested = false;
@@ -126,6 +118,8 @@ internal void Release(IReadOnlyList callIds)
}
catch (Exception failure) when (!IsCallerCancellation(failure, cancellationToken))
{
+ ToolFaultMark.Put(failure, callId);
+
invocation?.OnToolFailure?.Invoke(new ToolFailure
{
ToolName = context.CallContent.Name,
diff --git a/src/AgentCore.Application/Runtime/ConversationBusyMark.cs b/src/AgentCore.Application/Runtime/ConversationBusyMark.cs
new file mode 100644
index 00000000..c624f36c
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ConversationBusyMark.cs
@@ -0,0 +1,331 @@
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Diagnostics;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// The conversation's busy mark in the store.
+ ///
+#pragma warning disable CA1001 // _requests never hands out its wait handle, so it holds nothing to dispose.
+ internal sealed class ConversationBusyMark
+#pragma warning restore CA1001
+ {
+ ///
+ /// How long a mark lives after it was last put. A host that crashed frees the conversation this long after
+ /// its last renewal.
+ ///
+ internal static readonly TimeSpan Lease = TimeSpan.FromSeconds(10);
+
+ ///
+ /// How long a turn waits for another session's mark, or a host request for this session's earlier request, before
+ /// it is refused. It covers a stopped turn's work after the reply and the filing after the abort, each bounded by
+ /// , and the of a holder that crashed.
+ ///
+ internal static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(15);
+
+ private static readonly TimeSpan RenewEvery = Lease / 3;
+
+ /// How long a waiting turn first waits before it asks for the mark again. Each wait doubles, up to 250 ms.
+ internal static readonly TimeSpan FirstPoll = TimeSpan.FromMilliseconds(50);
+
+ private static readonly TimeSpan LastPoll = TimeSpan.FromMilliseconds(250);
+
+ private readonly ConversationSession _session;
+
+ private readonly string _holder = Guid.NewGuid().ToString("N");
+
+ private readonly Lock _gate = new();
+
+ // One host request at a time: requests of one live session share its holder, so the store mark alone lets them all in.
+ private readonly SemaphoreSlim _requests = new(1, 1);
+
+ // Guarded by _gate. _holds counts the holders that entered and have not left, waiting ones included.
+ private int _holds;
+
+ private bool _held;
+
+ private ITimer? _renewal;
+
+ private Task _writes = Task.CompletedTask;
+
+ internal ConversationBusyMark(ConversationSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ _session = session;
+ }
+
+ ///
+ /// Holds the mark: takes it from the store unless this session already holds it, waiting up to
+ /// while another session's mark is live. Each call is paired with one ,
+ /// made only once this returned.
+ ///
+ /// Cancels the wait.
+ /// Another session held the mark past the limit.
+ internal ValueTask EnterAsync(CancellationToken cancellationToken)
+ {
+ return HoldAsync(_session.Time.GetTimestamp(), cancellationToken);
+ }
+
+ private async ValueTask HoldAsync(long started, CancellationToken cancellationToken)
+ {
+ lock (_gate)
+ {
+ _holds++;
+ if (_held)
+ {
+ return;
+ }
+ }
+
+ // Two turns of one session that take at once both succeed: they mark under the same holder.
+ try
+ {
+ await TakeAsync(started, cancellationToken).ConfigureAwait(false);
+
+ lock (_gate)
+ {
+ _held = true;
+ _renewal ??= _session.Time.CreateTimer(_ => Renew(), state: null, RenewEvery, RenewEvery);
+ }
+ }
+ catch
+ {
+ _ = Exit();
+ throw;
+ }
+ }
+
+ ///
+ /// Holds the mark for one host request: waits for this session's earlier request, if any, to leave, holds the
+ /// mark as does, then waits for a turn another door started on this session to end. All
+ /// the waits together last at most from . Each call is paired
+ /// with one , made only once this returned.
+ ///
+ /// The at which the request began to wait.
+ /// Cancels the wait.
+ /// An earlier request, a turn or another session held on past the limit.
+ internal async ValueTask EnterRequestAsync(long started, CancellationToken cancellationToken)
+ {
+ TimeSpan left = WaitLimit - _session.Time.GetElapsedTime(started);
+
+ if (left <= TimeSpan.Zero)
+ {
+ throw Refuse(_session);
+ }
+
+ using (CancellationTokenSource limit = new(left, _session.Time))
+ using (CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, limit.Token))
+ {
+ try
+ {
+ await _requests.WaitAsync(wait.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone);
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ throw Refuse(_session);
+ }
+ }
+
+ try
+ {
+ await HoldAsync(started, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ _ = _requests.Release();
+ throw;
+ }
+
+ try
+ {
+ await _session.Cuts.WaitForNoTurnAsync(started, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ await ExitRequestAsync().ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ /// Releases the hold of one host request, and lets the next request of this session in once the mark is clear.
+ /// The clear, as returns it. It never faults.
+ internal async Task ExitRequestAsync()
+ {
+ try
+ {
+ await Exit().ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _requests.Release();
+ }
+ }
+
+ /// Releases one hold. The last one stops the renewal and clears the mark in the store.
+ /// The clear, for a host that answers only once the conversation is free. It never faults.
+ internal Task Exit()
+ {
+ lock (_gate)
+ {
+ if (--_holds > 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ _held = false;
+ _renewal?.Dispose();
+ _renewal = null;
+ }
+
+ return Enqueue(ClearAsync);
+ }
+
+ private async Task TakeAsync(long started, CancellationToken cancellationToken)
+ {
+ TimeSpan poll = FirstPoll;
+
+ try
+ {
+ while (!await Enqueue(() => MarkAsync(cancellationToken)).ConfigureAwait(false))
+ {
+ TimeSpan left = WaitLimit - _session.Time.GetElapsedTime(started);
+ if (left <= TimeSpan.Zero)
+ {
+ throw Refuse(_session);
+ }
+
+ await Task.Delay(poll < left ? poll : left, _session.Time, cancellationToken).ConfigureAwait(false);
+ poll = poll * 2 < LastPoll ? poll * 2 : LastPoll;
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone);
+ throw;
+ }
+ }
+
+ /// Refuses a turn that waited for the conversation, and raises its busy refusal.
+ /// The conversation the turn waited for.
+ /// The exception to throw.
+ internal static ConversationTurnConflictException Refuse(ConversationSession session)
+ {
+ TurnRefusals.Raise(session, turnIndex: null, TurnRefusals.Busy);
+ return new ConversationTurnConflictException(
+ $"Another request still held conversation '{session.ConversationId}' after {WaitLimit.TotalSeconds:0} s, "
+ + "so this turn was refused before it ran.");
+ }
+
+ private async Task MarkAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await _session.Compiled.ConversationStore
+ .TryMarkBusyAsync(_session.ConversationId, _holder, Lease, cancellationToken)
+ .ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // A store that cannot be marked never stops a turn: the store's turn check stays the backstop.
+ catch (Exception exception) when (exception is not OperationCanceledException)
+#pragma warning restore CA1031
+ {
+ Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception);
+ return true;
+ }
+ }
+
+ private void Renew()
+ {
+ _ = Enqueue(async () =>
+ {
+ lock (_gate)
+ {
+ if (!_held)
+ {
+ return false;
+ }
+ }
+
+ try
+ {
+ if (!await _session.Compiled.ConversationStore
+ .TryMarkBusyAsync(_session.ConversationId, _holder, Lease, CancellationToken.None)
+ .ConfigureAwait(false))
+ {
+ Log.BusyMarkLost(_session.Logger, _session.ConversationId);
+ }
+ }
+#pragma warning disable CA1031 // A renewal that fails is retried by the next tick, and the mark lapses at worst.
+ catch (Exception exception)
+#pragma warning restore CA1031
+ {
+ Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception);
+ }
+
+ return true;
+ });
+ }
+
+ private async Task ClearAsync()
+ {
+ lock (_gate)
+ {
+ // A turn entered since the release: its own mark takes this one over, so clearing would free a busy conversation.
+ if (_holds > 0)
+ {
+ return false;
+ }
+ }
+
+ try
+ {
+ await _session.Compiled.ConversationStore
+ .ClearBusyAsync(_session.ConversationId, _holder, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // A mark that cannot be cleared lapses after its lease.
+ catch (Exception exception)
+#pragma warning restore CA1031
+ {
+ Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Runs one store write of the mark after every write asked for before it. With none pending it runs at once,
+ /// on the caller's thread, so a store that answers synchronously starts a turn synchronously too.
+ ///
+ private Task Enqueue(Func> write)
+ {
+ TaskCompletionSource written = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Task before;
+
+ lock (_gate)
+ {
+ before = _writes;
+ _writes = written.Task;
+ }
+
+ return AfterAsync(before, write, written);
+ }
+
+ private static async Task AfterAsync(Task before, Func> write, TaskCompletionSource written)
+ {
+ try
+ {
+ await before.ConfigureAwait(false);
+ return await write().ConfigureAwait(false);
+ }
+ finally
+ {
+ written.SetResult();
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/ConversationCutTracker.cs b/src/AgentCore.Application/Runtime/ConversationCutTracker.cs
new file mode 100644
index 00000000..68a98ee2
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ConversationCutTracker.cs
@@ -0,0 +1,302 @@
+namespace AgentCore.Application.Runtime
+{
+ internal sealed class ConversationCutTracker
+ {
+ private readonly ConversationSession _session;
+
+ private bool _running;
+
+ private TaskCompletionSource? _freed;
+
+ private bool _closed;
+
+ private TurnCutSlot? _slot;
+
+ private int _slotTurnIndex;
+
+ private int _newestStarted = -1;
+
+ internal ConversationCutTracker(ConversationSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ _session = session;
+ }
+
+ /// Cuts one turn's reply by the Cut rule (design section 3).
+ /// The turn the user was seeing or hearing.
+ /// What reached the user.
+ ///
+ /// when the running turn took the cut, or the committed turn was rewritten to it;
+ /// for a turn older than the newest turn started, which is left as it is.
+ ///
+ internal bool Cut(int turnIndex, TurnCut cut)
+ {
+ ArgumentNullException.ThrowIfNull(cut);
+ if (cut.Played is { } played)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut));
+ }
+
+ lock (_session.TurnLock)
+ {
+ if (turnIndex < _newestStarted)
+ {
+ return false;
+ }
+
+ // A slot that refuses the cut has committed its turn, which is then the last turn.
+ if (_slot is { } slot && _slotTurnIndex == turnIndex && slot.TryCut(cut))
+ {
+ return true;
+ }
+
+ return AmendTurn(turnIndex, cut, recut: false);
+ }
+ }
+
+ /// Replaces the cut one turn already took, while it is the newest turn started.
+ /// The turn the user was seeing or hearing.
+ /// A later account of what reached the user.
+ ///
+ /// when the running turn took the recut, or the committed turn was rewritten to it;
+ /// for a turn that took no cut, or one older than the newest turn started.
+ ///
+ internal bool Recut(int turnIndex, TurnCut cut)
+ {
+ ArgumentNullException.ThrowIfNull(cut);
+ if (cut.Played is { } played)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut));
+ }
+
+ lock (_session.TurnLock)
+ {
+ if (turnIndex < _newestStarted)
+ {
+ return false;
+ }
+
+ if (_slot is { } slot && _slotTurnIndex == turnIndex && slot.TryRecut(cut))
+ {
+ return true;
+ }
+
+ return AmendTurn(turnIndex, cut, recut: true);
+ }
+ }
+
+ ///
+ /// Opens the window in which reaches this turn.
+ ///
+ /// The index of the turn about to run.
+ /// The token of the host.
+ /// The slot whose token the run reads. The caller ends it with .
+ internal TurnCutSlot StartRun(int turnIndex, CancellationToken cancellationToken)
+ {
+ TurnCutSlot slot = new(_session.TurnLock, cancellationToken);
+
+ lock (_session.TurnLock)
+ {
+ _slot = slot;
+ _slotTurnIndex = turnIndex;
+ _newestStarted = Math.Max(_newestStarted, turnIndex);
+ }
+
+ return slot;
+ }
+
+ ///
+ /// Takes the turn slot, waiting for a running turn of this conversation to free it, up to
+ /// counted from .
+ ///
+ /// The at which the wait for the conversation began.
+ /// Cancels the wait.
+ /// The conversation was disposed.
+ /// Another turn still ran past the limit.
+ internal Task EnterTurnAsync(long started, CancellationToken cancellationToken)
+ {
+ return WaitForSlotAsync(take: true, started, cancellationToken);
+ }
+
+ ///
+ /// Waits, as does, until no turn holds the slot, without taking it.
+ ///
+ /// The at which the wait for the conversation began.
+ /// Cancels the wait.
+ /// Another turn still ran past the limit.
+ internal Task WaitForNoTurnAsync(long started, CancellationToken cancellationToken)
+ {
+ return WaitForSlotAsync(take: false, started, cancellationToken);
+ }
+
+ private async Task WaitForSlotAsync(bool take, long started, CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ if (take && TryEnterTurn())
+ {
+ return;
+ }
+
+ Task freed;
+ lock (_session.TurnLock)
+ {
+ if (!_running)
+ {
+ // The turn ended since the take above failed: take again, or refuse a disposed conversation.
+ if (take)
+ {
+ continue;
+ }
+
+ return;
+ }
+
+ freed = FreedLocked();
+ }
+
+ TimeSpan left = ConversationBusyMark.WaitLimit - _session.Time.GetElapsedTime(started);
+ try
+ {
+ if (left <= TimeSpan.Zero)
+ {
+ throw new TimeoutException();
+ }
+
+ await freed.WaitAsync(left, _session.Time, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ throw ConversationBusyMark.Refuse(_session);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone);
+ throw;
+ }
+ }
+ }
+
+ /// Takes the turn slot, unless a turn of this conversation is still running.
+ /// when another turn holds the slot.
+ /// The conversation was disposed.
+ internal bool TryEnterTurn()
+ {
+ lock (_session.TurnLock)
+ {
+ if (_closed)
+ {
+ throw new ObjectDisposedException(
+ nameof(ConversationSession), $"The conversation '{_session.ConversationId}' was disposed, so it runs no further turn.");
+ }
+
+ if (_running)
+ {
+ return false;
+ }
+
+ _running = true;
+ return true;
+ }
+ }
+
+ ///
+ /// Refuses every later turn, and waits for the one that holds the slot, if any, to free it: a turn admitted
+ /// before may still swap the session in its catch-up and start children on it.
+ ///
+ /// Completes once no turn holds the slot. A run the caller started and never read nor disposed holds it for good.
+ internal Task CloseTurnsAsync()
+ {
+ lock (_session.TurnLock)
+ {
+ _closed = true;
+ return _running ? FreedLocked() : Task.CompletedTask;
+ }
+ }
+
+ /// Reads whether a turn holds the slot, without refusing any later turn.
+ /// What completes once the running turn frees the slot, or when no turn holds it.
+ internal Task? RunningTurn()
+ {
+ lock (_session.TurnLock)
+ {
+ return _running ? FreedLocked() : null;
+ }
+ }
+
+ private Task FreedLocked()
+ {
+ return (_freed ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task;
+ }
+
+ /// Frees the turn slot after a turn refused to start left it taken.
+ internal void ReleaseTurn()
+ {
+ TaskCompletionSource? freed;
+ lock (_session.TurnLock)
+ {
+ _running = false;
+ freed = _freed;
+ _freed = null;
+ }
+
+ _ = freed?.TrySetResult();
+ }
+
+ ///
+ /// Closes the window, and frees the session for the next turn.
+ ///
+ /// The slot returned.
+ internal void EndRun(TurnCutSlot slot)
+ {
+ // The field drops first. A cut that arrives now meets no slot to cancel, so it reaches
+ // the amendment path rather than a disposed one.
+ lock (_session.TurnLock)
+ {
+ _slot = null;
+ }
+
+ slot.Dispose();
+ ReleaseTurn();
+ }
+
+ ///
+ /// Rewrites the reply of the turn that committed last to what the user saw or heard. Runs under the turn lock.
+ ///
+ /// The turn the cut names. Only the last committed turn is amended.
+ /// What reached the user.
+ /// Whether the cut replaces one the turn already took, rather than cutting it for the first time.
+ ///
+ /// when the turn was amended, and when the turn is not the last
+ /// one, was already cut (or, for a recut, never was), or wrote no reply.
+ ///
+ internal bool AmendTurn(int turnIndex, TurnCut cut, bool recut)
+ {
+ // The chain has already closed, so nothing may be appended behind conversation.ended.
+ if (_session.Events.HasEnded
+ || _session.AmendableEventId is not { } completedEventId
+ || _session.LastTurn is not { } finished
+ || (finished.Cut is not null) != recut
+ || finished.TurnIndex != turnIndex
+ || _session.Ledger.Session() is not { } session)
+ {
+ return false;
+ }
+
+ string shown = (cut.ShownText ?? _session.AmendableText ?? finished.ReplyText).Trim();
+
+ if (!_session.History.RewriteReply(session, turnIndex, shown))
+ {
+ return false;
+ }
+
+ _session.LastTurn = finished with { ReplyText = shown, Cut = cut.Played ?? TimeSpan.Zero };
+ _session.AmendableText = shown;
+
+ _session.Events.RaiseReplyInterrupted(
+ finished.TurnIndex, _session.Time.GetUtcNow(), completedEventId, shown, cut.Played);
+
+ return true;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/ConversationEventChain.cs b/src/AgentCore.Application/Runtime/ConversationEventChain.cs
index 8ae80277..47b51cc2 100644
--- a/src/AgentCore.Application/Runtime/ConversationEventChain.cs
+++ b/src/AgentCore.Application/Runtime/ConversationEventChain.cs
@@ -89,13 +89,15 @@ internal bool EndConversation(ConversationEndReason reason, DateTimeOffset ended
/// Raises the durable facts of one finished turn, in the order they happened.
/// The turn that just spoke: its index, stages, the text the caller heard, when it ended, and how far it played.
- /// The whole reply the model produced.
+ /// The words turn.completed proves.
+ /// The words a cut turn's reply.interrupted proves.
/// The message of the fault, or .
+ /// How much of a cut reply played, or when no voice layer knows it.
///
/// The identity of the turn.completed fact, so a barge-in that arrives after this turn
/// already ended can name it through .
///
- internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? toolFault)
+ internal Guid WriteTurnEvents(TurnResult result, string generatedText, string heard, string? toolFault, TimeSpan? played)
{
(int turnIndex, DateTimeOffset endedAt) = (result.TurnIndex, result.EndedAt);
@@ -117,14 +119,14 @@ internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? too
turnIndex,
payload: new Dictionary(StringComparer.Ordinal)
{
- [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spokenReply).Value,
+ [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(generatedText).Value,
[AuditPayloadKeys.StageBefore] = result.StageBefore,
[AuditPayloadKeys.StageAfter] = result.StageAfter,
});
- if (result.InterruptedAfter is { } played)
+ if (result.Cut is not null)
{
- RaiseReplyInterrupted(turnIndex, endedAt, completed, result.ReplyText, played);
+ RaiseReplyInterrupted(turnIndex, endedAt, completed, heard, played);
}
return completed;
@@ -135,25 +137,29 @@ internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? too
/// When the cut was recorded.
/// The identity of the turn.completed fact it corrects.
/// The text the caller actually heard.
- /// How much of the reply played, as the relay reported it.
+ ///
+ /// How much of the reply played, as the relay reported it, or when nothing played it:
+ /// the payload then omits the duration (design section 7, item 5).
+ ///
internal void RaiseReplyInterrupted(
int turnIndex,
DateTimeOffset occurredAt,
Guid amendsEventId,
string heard,
- TimeSpan played)
+ TimeSpan? played)
{
- _ = Raise(
- ConversationEventKind.ReplyInterrupted,
- occurredAt,
- turnIndex,
- amends: amendsEventId,
- payload: new Dictionary(StringComparer.Ordinal)
- {
- [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(heard).Value,
- [AuditPayloadKeys.DurationUntilInterruptMs] =
- ((long)played.TotalMilliseconds).ToString(CultureInfo.InvariantCulture),
- });
+ Dictionary payload = new(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(heard).Value,
+ };
+
+ if (played is { } duration)
+ {
+ payload[AuditPayloadKeys.DurationUntilInterruptMs] =
+ ((long)duration.TotalMilliseconds).ToString(CultureInfo.InvariantCulture);
+ }
+
+ _ = Raise(ConversationEventKind.ReplyInterrupted, occurredAt, turnIndex, amends: amendsEventId, payload: payload);
}
/// Raises the moderation facts of one turn, before the turn's own events.
diff --git a/src/AgentCore.Application/Runtime/ConversationEventKind.cs b/src/AgentCore.Application/Runtime/ConversationEventKind.cs
index 98a997a3..5248267c 100644
--- a/src/AgentCore.Application/Runtime/ConversationEventKind.cs
+++ b/src/AgentCore.Application/Runtime/ConversationEventKind.cs
@@ -49,5 +49,14 @@ public enum ConversationEventKind
/// held. A message appended outside a turn since the last read is not among them.
///
TranscriptResyncFailed = 13,
+
+ /// A turn was refused or dropped, so none of its words were kept.
+ TurnRefused = 14,
+
+ ///
+ /// The turn's run faulted outside every tool, say because the model endpoint did not answer, so the turn spoke
+ /// the fallback. A tool that spent its retry budget is instead.
+ ///
+ RunFaulted = 15,
}
}
diff --git a/src/AgentCore.Application/Runtime/ConversationEventKinds.cs b/src/AgentCore.Application/Runtime/ConversationEventKinds.cs
index b971c28d..9ae0dce4 100644
--- a/src/AgentCore.Application/Runtime/ConversationEventKinds.cs
+++ b/src/AgentCore.Application/Runtime/ConversationEventKinds.cs
@@ -8,7 +8,7 @@ namespace AgentCore.Application.Runtime
///
internal static class ConversationEventKinds
{
- /// The seven kinds the chain stores, and the audit kind each one is stored as.
+ /// The eight kinds the chain stores, and the audit kind each one is stored as.
private static readonly FrozenDictionary AuditKinds =
new Dictionary
{
@@ -19,6 +19,7 @@ internal static class ConversationEventKinds
[ConversationEventKind.ReplyInterrupted] = AuditEventKind.ReplyInterrupted,
[ConversationEventKind.ConversationEnded] = AuditEventKind.ConversationEnded,
[ConversationEventKind.TurnSuperseded] = AuditEventKind.TurnSuperseded,
+ [ConversationEventKind.TurnRefused] = AuditEventKind.TurnRefused,
}.ToFrozenDictionary();
///
@@ -43,6 +44,7 @@ public static string ToToken(ConversationEventKind kind)
ConversationEventKind.ReplyInterrupted => AuditEventKinds.ToToken(AuditEventKind.ReplyInterrupted),
ConversationEventKind.ConversationEnded => AuditEventKinds.ToToken(AuditEventKind.ConversationEnded),
ConversationEventKind.TurnSuperseded => AuditEventKinds.ToToken(AuditEventKind.TurnSuperseded),
+ ConversationEventKind.TurnRefused => AuditEventKinds.ToToken(AuditEventKind.TurnRefused),
ConversationEventKind.ModerationUnavailable => "moderation.unavailable",
ConversationEventKind.ModerationClean => "moderation.clean",
ConversationEventKind.EmptyReply => "reply.empty",
@@ -50,6 +52,7 @@ public static string ToToken(ConversationEventKind kind)
ConversationEventKind.TranscriptWriteFailed => "transcript.write.failed",
ConversationEventKind.StateRestorePartial => "state.restore.partial",
ConversationEventKind.TranscriptResyncFailed => "transcript.resync.failed",
+ ConversationEventKind.RunFaulted => "run.faulted",
// A kind outside the closed set must not cost the report the fault it is carrying, so this
// names the value instead of throwing over it.
diff --git a/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs b/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs
deleted file mode 100644
index 8850de72..00000000
--- a/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs
+++ /dev/null
@@ -1,179 +0,0 @@
-namespace AgentCore.Application.Runtime
-{
- internal sealed class ConversationInterruptionTracker
- {
- private readonly ConversationSession _session;
-
- // One session runs one turn at a time. Lives here, with the rest of the live run, so the
- // guard and the release travel together; BeginTurn enters through TryEnterTurn.
- private int _running;
-
- // Whether the running turn has already handed the host something to speak. One rule for both
- // run shapes: a run that has handed the host nothing cannot be the turn the caller was hearing.
- private volatile bool _runIsAudible;
-
- ///
- /// Whether the running turn has handed the host something to speak.
- ///
- internal bool RunIsAudible { get => _runIsAudible; set => _runIsAudible = value; }
-
- internal ConversationInterruptionTracker(ConversationSession session)
- {
- ArgumentNullException.ThrowIfNull(session);
- _session = session;
- }
-
- ///
- /// Ends the running turn where the caller cut the reply off.
- ///
- /// The text the caller actually heard.
- /// How much of the reply played, as the relay reported it.
- ///
- /// Whether the turn running now is the turn the caller was hearing. An adapter that tracks which
- /// turn's output actually reached the vendor answers that question itself and says so here;
- /// sends the frame straight to the amendment path, whatever the running
- /// turn has produced. The default is , which leaves the decision to this
- /// session alone, and that is the right answer for a caller that speaks over a reply it is
- /// itself streaming.
- ///
- ///
- /// when this conversation recorded the barge-in, either by ending the running
- /// turn or by amending the turn that finished last, and when there was
- /// nothing to record it against.
- ///
- ///
- /// is negative.
- ///
- internal bool Interrupt(
- string utteranceUntilInterrupt,
- TimeSpan durationUntilInterrupt,
- bool cutsRunningTurn = true)
- {
- ArgumentNullException.ThrowIfNull(utteranceUntilInterrupt);
- ArgumentOutOfRangeException.ThrowIfLessThan(durationUntilInterrupt, TimeSpan.Zero);
-
- lock (_session.InterruptLock)
- {
- // Both conditions, and in this order. The adapter's answer only ever removes the
- // in-flight path: an adapter that knows the caller was hearing an earlier turn skips it
- // outright, and one that says nothing leaves this session's own audibility test to
- // decide.
- if (cutsRunningTurn && _session.RunCancellation is { } cancellation && RunIsAudible)
- {
- _session.Interruption = new Interruption(utteranceUntilInterrupt, durationUntilInterrupt);
- cancellation.Cancel();
- return true;
- }
-
- return AmendLastTurn(new Interruption(utteranceUntilInterrupt, durationUntilInterrupt));
- }
- }
-
- ///
- /// Opens the window in which reaches this turn.
- ///
- /// The token of the host.
- /// The source the run reads. The caller ends it with .
- internal CancellationTokenSource StartRun(CancellationToken cancellationToken)
- {
- CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-
- lock (_session.InterruptLock)
- {
- _session.Interruption = null;
- _session.RunCancellation = cancellation;
- _runIsAudible = false;
- }
-
- return cancellation;
- }
-
- /// Takes the turn slot, unless a turn of this conversation is still running.
- /// when another turn holds the slot.
- internal bool TryEnterTurn()
- {
- return Interlocked.Exchange(ref _running, 1) == 0;
- }
-
- /// Frees the turn slot after a turn refused to start left it taken.
- internal void ReleaseTurn()
- {
- Volatile.Write(ref _running, 0);
- }
-
- ///
- /// Closes the window, and frees the session for the next turn.
- ///
- /// The source returned.
- internal void EndRun(CancellationTokenSource cancellation)
- {
- // The field drops first. A frame that arrives now meets no source to cancel, so it reaches
- // the amendment path rather than a disposed one.
- lock (_session.InterruptLock)
- {
- _session.RunCancellation = null;
- _runIsAudible = false;
- }
-
- cancellation.Dispose();
- Volatile.Write(ref _running, 0);
- }
-
- ///
- /// Records a barge-in against the turn that finished last, and corrects its record.
- ///
- /// What the relay reported: the heard text and the played duration.
- ///
- /// when the turn was amended, and when there was
- /// no turn to amend.
- ///
- internal bool AmendLastTurn(Interruption cut)
- {
- // The chain has already closed, so nothing may be appended behind conversation.ended.
- if (_session.Events.HasEnded
- || _session.AmendableEventId is not { } completedEventId
- || _session.LastTurn is not { InterruptedAfter: null } finished
- || _session.Ledger.Session() is not { } session)
- {
- return false;
- }
-
- string heard = cut.HeardText.Trim();
-
- // Store 1 keeps the reply row and rewrites its words, rather than replacing the turn's
- // messages. The turn it corrects ran to its end, so every tool call it made is already
- // paired and there is nothing unfinished to strip out.
- //
- // The cut always lands today: a turn is amendable only when it completed with words, and a
- // turn that completed with words left a reply row holding them. The answer is still read,
- // because the row below names a HASH of those words — a chain that proves words store 1
- // does not hold proves nothing, so no cut means no amendment.
- if (!_session.History.TruncateLastReply(session, heard, cut.PlayedDuration))
- {
- return false;
- }
-
- _session.LastTurn = finished with { ReplyText = heard, InterruptedAfter = cut.PlayedDuration };
-
- _session.Events.RaiseReplyInterrupted(
- finished.TurnIndex, _session.Time.GetUtcNow(), completedEventId, heard, cut.PlayedDuration);
-
- return true;
- }
-
- /// Reads the interruption the relay reported for the running turn.
- /// The record, or when the caller did not interrupt.
- internal Interruption? CurrentInterruption()
- {
- lock (_session.InterruptLock)
- {
- return _session.Interruption;
- }
- }
-
- /// What the relay reported when the caller spoke over the reply.
- /// The text the caller actually heard.
- /// How much of the reply played, at 1 ms.
- internal sealed record Interruption(string HeardText, TimeSpan PlayedDuration);
- }
-}
diff --git a/src/AgentCore.Application/Runtime/ConversationSession.cs b/src/AgentCore.Application/Runtime/ConversationSession.cs
index 8e5859fb..325f6490 100644
--- a/src/AgentCore.Application/Runtime/ConversationSession.cs
+++ b/src/AgentCore.Application/Runtime/ConversationSession.cs
@@ -1,16 +1,16 @@
using System.Text.Json;
-using AgentCore.Application.Conversation;
using AgentCore.Application.Configuration.Compilation;
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Configuration.Validation;
+using AgentCore.Application.Conversation;
using AgentCore.Application.Policy;
using AgentCore.Application.Ports;
using AgentCore.Application.Runtime.Harness;
using AgentCore.Application.State;
using AgentCore.Application.Transcript;
using AgentCore.Domain;
-using AgentCore.Domain.Knowledge;
using AgentCore.Domain.Audit;
+using AgentCore.Domain.Knowledge;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
@@ -38,6 +38,13 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
///
public const string ToolFailureReason = "a tool failed four times in a row, so the turn spoke the fallback.";
+ ///
+ /// The reason a turn whose run faulted outside every tool reports, before the message of the fault: a model
+ /// endpoint that did not answer, or a graph that matched no edge. No tool let the fault out, so this is never
+ /// 's wording.
+ ///
+ public const string RunFaultReason = "the turn's run faulted, so it spoke the fallback.";
+
///
/// The failure the turn records when the completion work passes its deadline.
///
@@ -58,9 +65,6 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
///
internal static readonly TimeSpan TurnCompletionTimeout = TimeSpan.FromSeconds(5);
- // Phase 1 seam: one live-state owner, verb-split operators. The helpers below hold this
- // session and operate on the same fields under the same lock; only ownership of the verbs
- // moved, so every commit protocol is byte-identical to the single-file shape.
internal StagePolicy? Policy { get; }
internal StateExtractor? Extractor { get; }
@@ -79,7 +83,7 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
internal ILogger Logger { get; }
- internal Lock InterruptLock { get; } = new();
+ internal Lock TurnLock { get; } = new();
internal ConversationShells? Shells { get; }
@@ -100,12 +104,11 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
///
internal JsonElement? GraphBlob { get; set; }
- internal CancellationTokenSource? RunCancellation { get; set; }
-
- internal ConversationInterruptionTracker.Interruption? Interruption { get; set; }
-
internal Guid? AmendableEventId { get; set; }
+ /// The words the rows of the turn names say now.
+ internal string? AmendableText { get; set; }
+
internal ConversationSessionState? Checkpoint { get; set; }
internal Clarifications Clarifications { get; } = new();
@@ -114,11 +117,9 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
internal ConversationTurnStream Stream { get; }
- internal ConversationTurnCompletion Completion { get; }
-
internal ConversationTurnWriters Writers { get; }
- internal ConversationInterruptionTracker Interruptions { get; }
+ internal ConversationCutTracker Cuts { get; }
internal ConversationTranscriptLedger Ledger { get; }
@@ -126,6 +127,8 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
internal ConversationSessionLifetime Lifetime { get; }
+ internal ConversationBusyMark Busy { get; }
+
///
/// Creates the session of one conversation.
///
@@ -160,43 +163,33 @@ internal ConversationSession(
Time = timeProvider;
- // The seam is optional and it has a working default. A host that binds nothing to watch the
- // conversation still answers it, and the library never throws for want of an observer.
Events = new ConversationEventChain(conversationId, observers ?? new ConversationObserverDispatcher([]), timeProvider);
StartedAt = timeProvider.GetUtcNow();
- // An entry with no policy: has no stage machine. The single-agent row and both graph rows
- // read that way, and neither of them ever ends a conversation by itself.
Policy = compiled.Policy is null ? null : compiled.CreatePolicy(guards);
State = new StateDocument(compiled.Configuration, Policy?.Stage);
- // The writers run in a fixed order, and this is its only record: const slots land before
- // any turn, then each turn applies tool results, the extractor, the clock fields and the
- // counters, in CompleteTurnAsync. Guards read the finished document, so the order is
- // load-bearing.
_ = ConstStateWriter.Apply(State);
- // The first fact of this SESSION, and not necessarily of the conversation: a resumed conversation raises a
- // second one, behind the turns of the session before it. Nothing allocates a position here
- // any more — store 3 assigns the sequence — so a second one collides with nothing, and it is
- // raised rather than suppressed because a session picking the conversation up is a fact that
- // happened and the chain is where facts that happened go. Suppressing it is also not
- // available from here: knowing whether the conversation already has words needs the store read that
- // only OpenSessionAsync can do, and moving the raise there would let a conversation opened, never
- // spoken to, and then ended through EndConversation write a conversation.ended with no conversation.started in front
- // of it.
_ = Events.Raise(ConversationEventKind.ConversationStarted, StartedAt, turnIndex: null);
Runner = new ConversationTurnRunner(this);
+
Stream = new ConversationTurnStream(this);
- Completion = new ConversationTurnCompletion(this);
+
Writers = new ConversationTurnWriters(this);
- Interruptions = new ConversationInterruptionTracker(this);
+
+ Cuts = new ConversationCutTracker(this);
+
Ledger = new ConversationTranscriptLedger(this);
+
States = new ConversationSessionStateStore(this);
+
Lifetime = new ConversationSessionLifetime(this);
+
+ Busy = new ConversationBusyMark(this);
}
///
@@ -291,7 +284,7 @@ public Task RunTurnAtOriginAsync(
{
ArgumentNullException.ThrowIfNull(userInput);
- return Runner.RunTurnCoreAsync(new ChatMessage(ChatRole.User, userInput), origin, cancellationToken);
+ return Stream.RunTurnCoreAsync(new ChatMessage(ChatRole.User, userInput), origin, cancellationToken);
}
///
@@ -303,13 +296,13 @@ public Task RunTurnMessageAtOriginAsync(
{
ArgumentNullException.ThrowIfNull(userInput);
- return Runner.RunTurnCoreAsync(userInput, origin, cancellationToken);
+ return Stream.RunTurnCoreAsync(userInput, origin, cancellationToken);
}
/// Runs one turn and streams the reply as it arrives.
/// The reply, one update at a time. Every update carries content.
///
- /// The conversation already reached a terminal stage, another turn of this conversation is still running, or the
+ /// The conversation already reached a terminal stage, another turn of this conversation still ran after the wait limit, or the
/// stage the machine holds names no agent.
///
public IAsyncEnumerable RunTurnStreamingAsync(
@@ -321,7 +314,7 @@ public IAsyncEnumerable RunTurnStreamingAsync(
/// Runs one turn from a message the caller built and streams the reply as it arrives.
/// The reply, one update at a time. Every update carries content.
///
- /// The conversation already reached a terminal stage, another turn of this conversation is still running, or the
+ /// The conversation already reached a terminal stage, another turn of this conversation still ran after the wait limit, or the
/// stage the machine holds names no agent.
///
public IAsyncEnumerable RunTurnMessageStreamingAsync(
@@ -376,15 +369,23 @@ public IAsyncEnumerable RunTurnMessageStreamingAtOriginAsync
return Stream.RunTurnStreamingCoreAsync(userInput, origin, cancellationToken);
}
- ///
- /// Ends the running turn where the caller cut the reply off.
- ///
- public bool Interrupt(
- string utteranceUntilInterrupt,
- TimeSpan durationUntilInterrupt,
- bool cutsRunningTurn = true)
+ ///
+ public Task StartTurnAsync(
+ ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default)
+ {
+ return Stream.StartTurnAsync(userInput, origin, cancellationToken);
+ }
+
+ ///
+ public bool Cut(int turnIndex, TurnCut cut)
+ {
+ return Cuts.Cut(turnIndex, cut);
+ }
+
+ ///
+ public bool Recut(int turnIndex, TurnCut cut)
{
- return Interruptions.Interrupt(utteranceUntilInterrupt, durationUntilInterrupt, cutsRunningTurn);
+ return Cuts.Recut(turnIndex, cut);
}
/// Closes the conversation, and writes the last event of its chain.
@@ -394,8 +395,8 @@ public bool EndConversation(ConversationEndReason reason)
}
///
- /// Disposes this conversation's background sessions and shell executors. Idempotent: a session already
- /// disposed, or one that never had either, disposes nothing.
+ /// Refuses every later turn, waits for a running one to end, and disposes this conversation's background sessions
+ /// and shell executors. Idempotent: a session already disposed, or one that never had either, disposes nothing.
///
public ValueTask DisposeAsync()
{
@@ -411,10 +412,10 @@ public Task FlushTranscriptAsync()
}
///
- /// Reads the state this conversation would resume from, as it stands right now. The provider state is
- /// read off the live bag with no turn lock around it, so a host serializing mid-turn gets the
- /// bag as it stands at that instant, not a turn-boundary snapshot. A graph row that reuses
- /// its session instead attaches that session's last turn-end serialization.
+ /// Reads the state this conversation would resume from, as it stands right now. A host serializing mid-turn
+ /// gets the provider state off the live bag at that instant, not a turn-boundary snapshot, read under the turn
+ /// lock so it never falls inside a catch-up's swap. A graph row that reuses its session instead attaches that
+ /// session's last turn-end serialization.
///
internal ConversationSessionState Snapshot()
{
diff --git a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs b/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs
index 67b40d61..dc396d89 100644
--- a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs
+++ b/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs
@@ -69,7 +69,9 @@ public ConversationSession Create(string? conversationId = null, ConversationSes
{
string resolvedConversationId = string.IsNullOrWhiteSpace(conversationId) ? Guid.NewGuid().ToString("N") : conversationId;
- ConversationWorkspace? workspace = _workspaceRoot is null ? null : ConversationWorkspace.Create(_workspaceRoot, resolvedConversationId);
+ ConversationWorkspace? workspace = _workspaceRoot is null
+ ? null
+ : ConversationWorkspace.Create(_workspaceRoot, resolvedConversationId, _seams.Time, _seams.Logger);
ConversationSession session = new(
resolvedConversationId,
diff --git a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs b/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs
index 37261653..ef737bec 100644
--- a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs
+++ b/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs
@@ -1,5 +1,7 @@
+using AgentCore.Application.Diagnostics;
using AgentCore.Application.Runtime.Harness;
using AgentCore.Domain.Audit;
+using Microsoft.Agents.AI;
namespace AgentCore.Application.Runtime
{
@@ -7,10 +9,12 @@ internal sealed class ConversationSessionLifetime
{
private readonly ConversationSession _session;
- // The background release runs once per conversation: both DisposeAsync and the turn-end path reach
- // it, and a second release would re-cancel children that already went away.
+ private readonly Lock _replacedGate = new();
+
private int _backgroundReleased;
+ private Task _replacedReleases = Task.CompletedTask;
+
internal ConversationSessionLifetime(ConversationSession session)
{
ArgumentNullException.ThrowIfNull(session);
@@ -28,17 +32,17 @@ internal ConversationSessionLifetime(ConversationSession session)
///
internal bool EndConversation(ConversationEndReason reason)
{
- // The event is written before the flag moves. A value outside the closed set therefore ends
- // no conversation and writes nothing.
bool wrote = _session.Events.EndConversation(reason, _session.Time.GetUtcNow());
+
_session.IsComplete = true;
+
DeleteWorkspace();
return wrote;
}
///
- /// Deletes this conversation's workspace, if it has one. Called once the chain has recorded why the
- /// conversation ended, from every path that can end it.
+ /// Deletes this conversation's workspace, if it has one. Called when the conversation ends, and by the
+ /// session owner when it closes or unloads the session.
///
internal void DeleteWorkspace()
{
@@ -46,15 +50,71 @@ internal void DeleteWorkspace()
}
///
- /// Disposes this conversation's background sessions and shell executors. Idempotent: a session already
- /// disposed, or one that never had either, disposes nothing.
+ /// Stamps this conversation's workspace with now, if it has one. Called at the start of every turn, and by
+ /// the held-session idle poll while a running turn or a background child keeps the session alive past its
+ /// idle timeout, so a boot sweep sharing the workspace root never mistakes an active folder for a dead one.
+ ///
+ internal void TouchWorkspace()
+ {
+ _session.WorkspaceRoot?.Touch(_session.Time, _session.Logger);
+ }
+
+ ///
+ /// Refuses every later turn, waits for a turn still holding the conversation to end, then disposes this
+ /// conversation's background sessions and shell executors, and waits for the release of every session a
+ /// catch-up replaced. Idempotent: a session already disposed, or one that never had either, disposes nothing.
///
internal async ValueTask DisposeAsync()
{
+ await _session.Cuts.CloseTurnsAsync().ConfigureAwait(false);
await ReleaseBackgroundSessionsAsync().ConfigureAwait(false);
+ await ReplacedReleases().ConfigureAwait(false);
await DisposeShellsAsync().ConfigureAwait(false);
}
+ ///
+ /// Starts the release of a session a catch-up replaced, off the turn path, and keeps it for
+ /// to wait for. Its outcome is logged, never thrown.
+ ///
+ /// The session the catch-up swapped out. Nothing may read it any more.
+ internal void ReleaseReplaced(AgentSession replaced)
+ {
+ if (_session.Compiled.BackgroundProviders.Count == 0)
+ {
+ return;
+ }
+
+ Task release = Task.Run(() => ReleaseReplacedAsync(replaced));
+
+ lock (_replacedGate)
+ {
+ _replacedReleases = Task.WhenAll(_replacedReleases, release);
+ }
+ }
+
+ private async Task ReleaseReplacedAsync(AgentSession replaced)
+ {
+ try
+ {
+ await BackgroundSessionRelease.ReleaseAsync(
+ _session.Compiled.BackgroundProviders, replaced, _session.ConversationId, _session.Logger, CancellationToken.None).ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Nothing awaits this release until the conversation is disposed, so nothing may escape it.
+ catch (Exception exception)
+#pragma warning restore CA1031
+ {
+ SessionOwnerLog.BackgroundReleaseFailed(_session.Logger, _session.ConversationId, exception);
+ }
+ }
+
+ private Task ReplacedReleases()
+ {
+ lock (_replacedGate)
+ {
+ return _replacedReleases;
+ }
+ }
+
///
/// Cancels the background children still running on this conversation's session and releases it, once
/// per conversation. Skipped when the conversation never opened a session or the document names no background
@@ -82,5 +142,53 @@ internal ValueTask DisposeShellsAsync()
{
return _session.Shells?.DisposeAsync() ?? ValueTask.CompletedTask;
}
+
+ ///
+ /// Gets whether this conversation's document declares any background: children at all, whether or
+ /// not one has ever actually run. The held-session idle check reads this to decide whether it must poll
+ /// for a running child at all, or can keep the single sleep to the idle timeout it always had.
+ ///
+ internal bool MayHaveBackgroundChildren => _session.Compiled.BackgroundProviders.Count > 0;
+
+ ///
+ /// Gets whether a background: child this conversation started is still running. The held-session
+ /// idle check polls this to keep the session while a child outlives the turn that started it (issue #31).
+ ///
+ ///
+ /// is a snapshot, not something a caller can
+ /// await, so this only answers "right now": the poller in HeldSession calls it again later. A
+ /// provider that cannot read its own session state — a state bag shaped for a different provider version,
+ /// say — throws instead of answering; that is read as "still running" rather than let the idle timer
+ /// unload a session out from under a child that, for all this can tell, is still there.
+ ///
+#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0.
+ internal bool HasRunningBackgroundChild()
+ {
+ if (_session.Compiled.BackgroundProviders.Count == 0 || _session.Ledger.Session() is not { } session)
+ {
+ return false;
+ }
+
+ foreach (BackgroundAgentsProvider provider in _session.Compiled.BackgroundProviders)
+ {
+ try
+ {
+ if (provider.GetIncompleteTasks(session).Count > 0)
+ {
+ return true;
+ }
+ }
+#pragma warning disable CA1031 // The idle timer thread must never crash on a provider's snapshot fault.
+ catch (Exception exception)
+#pragma warning restore CA1031
+ {
+ SessionOwnerLog.BackgroundChildCheckFailed(_session.Logger, _session.ConversationId, exception);
+ return true;
+ }
+ }
+
+ return false;
+ }
+#pragma warning restore MAAI001
}
}
diff --git a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs b/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs
index 2765ab0a..9a363330 100644
--- a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs
+++ b/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs
@@ -2,6 +2,7 @@
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Conversation;
using AgentCore.Application.Runtime.Harness;
+using AgentCore.Application.State;
namespace AgentCore.Application.Runtime
{
@@ -87,31 +88,47 @@ void Dropped(string reason)
}
///
- /// Reads the state this conversation would resume from, as it stands right now. The provider state is
- /// read off the live bag with no turn lock around it, so a host serializing mid-turn gets the
- /// bag as it stands at that instant, not a turn-boundary snapshot. A graph row that reuses
- /// its session instead attaches that session's last turn-end serialization.
+ /// Takes the state another session of this conversation stored after this one last ran a turn. The session
+ /// then holds what a session opened on that state now would: its stage, and its slots and no others.
+ ///
+ /// The state store 0 holds, newer than this session's.
+ internal void CatchUp(ConversationSessionState stored)
+ {
+ if (stored.Version == ConversationSessionState.CurrentVersion)
+ {
+ _session.State.ClearWrittenSlots();
+ _ = ConstStateWriter.Apply(_session.State);
+ }
+
+ Restore(stored);
+ }
+
+ ///
+ /// Reads the state this conversation would resume from, as it stands right now. A host serializing mid-turn
+ /// gets the provider state off the live bag at that instant, not a turn-boundary snapshot. The read holds the
+ /// turn lock so that it never falls inside a catch-up, which swaps the session and moves the turn index
+ /// together. A graph row that reuses its session instead attaches that session's last turn-end serialization.
///
internal ConversationSessionState Snapshot()
{
- lock (_session.InterruptLock)
+ lock (_session.TurnLock)
{
if (_session.AgentSession is null && _session.Checkpoint is { } held)
{
return held;
}
- }
- return new()
- {
- Stage = _session.State.Stage,
- IsComplete = _session.IsComplete,
- Slots = _session.State.WrittenSlots(),
- NextTurnIndex = _session.State.TurnIndex,
- Clarifications = _session.Clarifications.Spent(),
- Providers = HarnessSessionState.Capture(_session.AgentSession, _session.Compiled.HarnessStateKeys),
- WorkflowState = _session.GraphBlob,
- };
+ return new()
+ {
+ Stage = _session.State.Stage,
+ IsComplete = _session.IsComplete,
+ Slots = _session.State.WrittenSlots(),
+ NextTurnIndex = _session.State.TurnIndex,
+ Clarifications = _session.Clarifications.Spent(),
+ Providers = HarnessSessionState.Capture(_session.AgentSession, _session.Compiled.HarnessStateKeys),
+ WorkflowState = _session.GraphBlob,
+ };
+ }
}
/// Names the state this conversation resumes from when store 0 holds none of its own.
@@ -119,7 +136,7 @@ internal void Resume(ConversationSessionState stored)
{
ArgumentNullException.ThrowIfNull(stored);
- lock (_session.InterruptLock)
+ lock (_session.TurnLock)
{
if (_session.AgentSession is not null)
{
diff --git a/src/AgentCore.Application/Runtime/ConversationStoreReads.cs b/src/AgentCore.Application/Runtime/ConversationStoreReads.cs
new file mode 100644
index 00000000..b9c2731b
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ConversationStoreReads.cs
@@ -0,0 +1,180 @@
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Transcript;
+using Microsoft.Agents.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Reads what store 1 holds beyond a session's words, and counts the session's writes the store did not take, so a
+ /// catch-up knows whether another session wrote in their place.
+ ///
+ internal sealed class ConversationStoreReads : ITranscriptLossCounter
+ {
+ private readonly ConversationSession _session;
+
+ private readonly TranscriptLosses _losses = new();
+
+ internal ConversationStoreReads(ConversationSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ _session = session;
+ }
+
+ ///
+ /// Gets whether the store lost a write of this session since it last caught up: it refused a turn, or the write
+ /// failed. The session's words then no longer match the store, so its next turn reads the store back first.
+ ///
+ internal bool Diverged => _losses.Any;
+
+ ///
+ /// Gets whether the store refused a turn of this session because another session of the conversation saved that
+ /// turn first, and the session has not caught up since. Such a session is never filed for a later request to
+ /// resume: the ids already name the session whose turn the store kept. A write that merely failed is not a
+ /// refusal: nothing else holds its ids, and store 0 outranks a filed session on resume.
+ ///
+ internal bool Refused => _losses.Refused;
+
+ /// Records that the store refused a turn this session committed.
+ internal void MarkRefused()
+ {
+ _losses.Lose([], refused: true);
+ }
+
+ ///
+ public void Dropped(int turnIndex, IReadOnlyList appended, Exception exception)
+ {
+ _losses.Lose(appended, exception is ConversationTurnConflictException);
+ _session.Events.RaiseDroppedTranscriptWrite(turnIndex, exception);
+ }
+
+ /// Records that the session took what read, and every loss counted before that read.
+ internal void CaughtUp(TranscriptCatchUp catchUp)
+ {
+ _losses.CaughtUp(catchUp.LossMark);
+ }
+
+ ///
+ public void Withdrew(IReadOnlyList messageIds)
+ {
+ _losses.Withdrew(messageIds);
+ }
+
+ ///
+ public async ValueTask JudgeAsync(AgentSession session, CancellationToken cancellationToken)
+ {
+ return Diverged && await ReadAsync(session, cancellationToken).ConfigureAwait(false) is { } read
+ ? new TranscriptLossVerdict(read.LossMark, read.Overtaken)
+ : null;
+ }
+
+ ///
+ public void Realigned(TranscriptLossVerdict verdict)
+ {
+ _losses.Realigned(verdict);
+ }
+
+ ///
+ /// Reads store when someone else wrote to the conversation since the session's words were last in line with
+ /// it, or always once the store lost one of this session's writes. Changes nothing.
+ ///
+ /// The session of this conversation.
+ /// Cancels the reads.
+ /// What store 1 holds, or when it holds nothing the session lacks, or cannot be read.
+ internal async ValueTask ReadAsync(AgentSession opened, CancellationToken cancellationToken)
+ {
+ TranscriptPosition position = _session.History.Position(opened);
+ await position.Written.ConfigureAwait(false);
+
+ // Read once the writes above landed or dropped, so every loss among them is counted.
+ (int mark, IReadOnlyList since) = _losses.Read();
+
+ try
+ {
+ ConversationRecord refreshed = await RecordAsync(cancellationToken).ConfigureAwait(false);
+
+ // A lost turn's words took the same ordinals another session's saved turn did, so the counters can
+ // match while the words differ.
+ if (since.Count == 0 && refreshed.NextOrdinal == position.NextOrdinal)
+ {
+ return null;
+ }
+
+ IReadOnlyList rows = await _session.Compiled.ConversationStore.ReadForSessionAsync(
+ _session.ConversationId, cancellationToken).ConfigureAwait(false);
+
+ return new TranscriptCatchUp(position.Revision, rows, refreshed.NextOrdinal, refreshed.State)
+ {
+ LossMark = mark,
+ Overtaken = since.Count > 0
+ && await OvertakenAsync(since, rows, refreshed.NextOrdinal, position.NextOrdinal, cancellationToken).ConfigureAwait(false),
+ };
+ }
+#pragma warning disable CA1031 // A store that cannot be read never ends a conversation: the turn runs on the words the session holds.
+ catch (Exception exception) when (exception is not OperationCanceledException)
+#pragma warning restore CA1031
+ {
+ Log.TranscriptResyncFailed(_session.Logger, _session.ConversationId, _session.State.TurnIndex, exception);
+ _session.Events.RaiseFailedTranscriptResync(_session.State.TurnIndex, exception);
+ return null;
+ }
+ }
+
+ /// Whether another session's writes stand in the store in place of this session's lost ones.
+ /// The losses since the last catch-up.
+ /// What store 1 read for the session.
+ /// The next ordinal by store 0's own counter.
+ /// The next ordinal as the session counted it.
+ /// Cancels the reads.
+ private async ValueTask OvertakenAsync(
+ IReadOnlyList since, IReadOnlyList rows, int stored, int own, CancellationToken cancellationToken)
+ {
+ if (since.Any(static loss => loss.Refused || loss.Overtaken))
+ {
+ return true;
+ }
+
+ HashSet read = new(rows.Select(static row => row.MessageId), StringComparer.Ordinal);
+ int unlanded = 0;
+ HashSet untold = [0];
+
+ foreach (TranscriptLoss loss in since)
+ {
+ if (loss.Appended.Count > 0 && loss.Held.Count == 0)
+ {
+ untold.UnionWith([.. untold.Select(rowsUntold => rowsUntold + loss.Appended.Count)]);
+ }
+ else if (!await LandedAsync(loss.Held, read, cancellationToken).ConfigureAwait(false))
+ {
+ unlanded += loss.Appended.Count;
+ }
+ }
+
+ return !untold.Contains(own - unlanded - stored);
+ }
+
+ /// Whether the store holds every one of these rows, including a row a newer summary now stands in for.
+ private async ValueTask LandedAsync(IReadOnlyList held, HashSet read, CancellationToken cancellationToken)
+ {
+ foreach (string messageId in held)
+ {
+ if (!read.Contains(messageId)
+ && await _session.Compiled.ConversationStore.OrdinalOfAsync(_session.ConversationId, messageId, cancellationToken).ConfigureAwait(false) is null)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// Store 0's record of this conversation, which must exist for a session that already opened it.
+ /// Cancels the read.
+ private async ValueTask RecordAsync(CancellationToken cancellationToken)
+ {
+ return await _session.Compiled.ConversationStore.GetAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidOperationException(
+ $"Store 0 holds no conversation '{_session.ConversationId}' for its own session to resync against.");
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs b/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs
index e6e07502..b8294e4e 100644
--- a/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs
@@ -1,6 +1,4 @@
using AgentCore.Application.Conversation;
-using AgentCore.Application.Diagnostics;
-using AgentCore.Application.Ports;
using AgentCore.Application.Runtime.Harness;
using AgentCore.Application.Transcript;
using Microsoft.Agents.AI;
@@ -16,26 +14,187 @@ internal ConversationTranscriptLedger(ConversationSession session)
{
ArgumentNullException.ThrowIfNull(session);
_session = session;
+ Reads = new ConversationStoreReads(session);
}
+ /// Gets the reads of what store 1 holds beyond the session's words, and the count of its lost writes.
+ internal ConversationStoreReads Reads { get; }
+
///
- /// Opens the session of this conversation, once, and re-syncs it with store 0 before handing it to every
- /// later turn.
+ /// Opens the session for one turn, and reads what store 1 holds beyond its words. Changes no words the
+ /// session already holds: the turn is not admitted yet, and a refused turn must leave them as they are.
///
+ /// Cancels the open and the reads.
+ ///
+ /// The session, and what brings in once the turn is admitted:
+ /// for a session opened just now, or one whose words store 1 already matches.
+ ///
+ internal async ValueTask<(AgentSession Session, TranscriptCatchUp? CatchUp)> OpenForTurnAsync(CancellationToken cancellationToken)
+ {
+ if (Session() is { } opened)
+ {
+ return (opened, await Reads.ReadAsync(opened, cancellationToken).ConfigureAwait(false));
+ }
+
+ return (await OpenSessionAsync(cancellationToken).ConfigureAwait(false), null);
+ }
+
+ ///
+ /// Brings an admitted turn's session in line with what store 1 held. A read made before the turn was admitted
+ /// is stale once the session's own words moved, say because the turn before committed, so it is made again: no
+ /// turn can commit now, and the words are never replaced by rows that lack them.
+ ///
+ /// The session of this conversation.
+ /// What read, or .
+ /// Cancels the reads.
+ /// Another session's turn ended the conversation.
+ internal async ValueTask CatchUpAsync(AgentSession opened, TranscriptCatchUp? catchUp, CancellationToken cancellationToken)
+ {
+ if (catchUp is not null)
+ {
+ await BringInAsync(opened, catchUp, cancellationToken).ConfigureAwait(false);
+ }
+
+ _session.Runner.RefuseIfComplete();
+ }
+
+ private async ValueTask BringInAsync(AgentSession opened, TranscriptCatchUp catchUp, CancellationToken cancellationToken)
+ {
+ if (!await TakeStoredAsync(opened, catchUp, StateToTake(catchUp), cancellationToken).ConfigureAwait(false))
+ {
+ if (await Reads.ReadAsync(opened, cancellationToken).ConfigureAwait(false) is not { } again
+ || !await TakeStoredAsync(opened, again, StateToTake(again), cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ catchUp = again;
+ }
+
+ Reads.CaughtUp(catchUp);
+ }
+
+ /// The state another session stored that this one takes, or when it keeps its own.
+ private ConversationSessionState? StateToTake(TranscriptCatchUp catchUp)
+ {
+ return catchUp.State is { } stored && (catchUp.Overtaken || stored.NextTurnIndex > _session.State.TurnIndex) ? stored : null;
+ }
+
+ ///
+ /// Takes the words store 1 holds, with the turn index, the stage, the slots, and the harness providers' state or
+ /// a graph row's workflow another session stored beside them.
+ ///
+ /// The session of this conversation.
+ /// What store 1 held.
+ /// The state to take, or when the session keeps its own.
+ /// Cancels the session's rebuild.
+ /// when the session's words moved since the read and nothing was taken.
+ private async ValueTask TakeStoredAsync(
+ AgentSession opened, TranscriptCatchUp catchUp, ConversationSessionState? stored, CancellationToken cancellationToken)
+ {
+ if (stored is { Version: ConversationSessionState.CurrentVersion } current
+ && _session.SessionCarriesHistory && _session.Compiled.HarnessStateKeys.Count > 0)
+ {
+ return await RebuildAsync(opened, catchUp, current, cancellationToken).ConfigureAwait(false);
+ }
+
+ ConversationSessionState? workflow = _session.ReusesGraphSession && stored is { Version: ConversationSessionState.CurrentVersion }
+ ? stored
+ : null;
+ AgentSession? graph = workflow is null ? null : await GraphSessionFromAsync(workflow, cancellationToken).ConfigureAwait(false);
+
+ if (!_session.History.TryResync(opened, catchUp))
+ {
+ return false;
+ }
+
+ lock (_session.TurnLock)
+ {
+ if (workflow is not null)
+ {
+ _session.GraphSession = graph;
+ _session.GraphBlob = workflow.WorkflowState;
+ }
+
+ TakeState(catchUp, stored);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Replaces the session with one read from the state another session stored, holding the words store 1 read,
+ /// and releases the old one.
+ ///
+ /// The session of this conversation.
+ /// What store 1 held.
+ /// The state store 0 holds.
+ /// Cancels the read of the new session.
+ /// when the old session's words moved since the read and it was kept.
+ private async ValueTask RebuildAsync(
+ AgentSession opened, TranscriptCatchUp catchUp, ConversationSessionState stored, CancellationToken cancellationToken)
+ {
+ if (_session.History.Position(opened).Revision != catchUp.Revision)
+ {
+ return false;
+ }
+
+ AgentSession rebuilt = await SessionFromAsync(stored, cancellationToken).ConfigureAwait(false);
+
+ lock (_session.TurnLock)
+ {
+ if (_session.History.Position(opened).Revision != catchUp.Revision)
+ {
+ return false;
+ }
+
+ _ = _session.History.BeginConversation(
+ rebuilt,
+ _session.ConversationId,
+ catchUp.Rows,
+ Reads,
+ new TranscriptMarks(catchUp.NextOrdinal, stored.NextTurnIndex));
+
+ _session.AgentSession = rebuilt;
+ TakeState(catchUp, stored);
+ }
+
+ _session.Lifetime.ReleaseReplaced(opened);
+
+ return true;
+ }
+
+ ///
+ /// Moves the turn index on past the turns store 1 holds, and takes the stage and slots another session stored.
+ /// The caller holds , and swaps the session or the workflow in the same
+ /// section: a snapshot must never pair one session's providers with another's turn index.
+ ///
+ /// What store 1 held.
+ /// The state to take, or when the session keeps its own.
+ private void TakeState(TranscriptCatchUp catchUp, ConversationSessionState? stored)
+ {
+ if (stored is not null)
+ {
+ _session.States.CatchUp(stored);
+ }
+
+ _session.State.TurnIndex = Math.Max(_session.State.TurnIndex, catchUp.NextTurnIndex);
+ }
+
+ /// Opens the session of this conversation, once, and hands the same session to every later caller.
/// Cancels the open.
/// The session.
internal async ValueTask OpenSessionAsync(CancellationToken cancellationToken)
{
if (Session() is { } opened)
{
- await ResyncTranscriptAsync(opened, cancellationToken).ConfigureAwait(false);
return opened;
}
ConversationRecord record = await _session.Compiled.ConversationStore.CreateAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false);
ConversationSessionState? checkpoint;
- lock (_session.InterruptLock)
+ lock (_session.TurnLock)
{
checkpoint = _session.Checkpoint;
}
@@ -45,10 +204,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel
AgentSession session;
if (_session.SessionCarriesHistory)
{
- session = stored is { Version: ConversationSessionState.CurrentVersion, Providers.Count: > 0 }
- ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(
- HarnessSessionState.Wrap(stored.Providers), cancellationToken: cancellationToken).ConfigureAwait(false)
- : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ session = await SessionFromAsync(stored, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -56,9 +212,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel
if (_session.ReusesGraphSession)
{
- _session.GraphSession = stored is { Version: ConversationSessionState.CurrentVersion, WorkflowState: { } blob }
- ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(blob, cancellationToken: cancellationToken).ConfigureAwait(false)
- : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ _session.GraphSession = await GraphSessionFromAsync(stored, cancellationToken).ConfigureAwait(false);
}
}
@@ -66,7 +220,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel
? []
: await _session.Compiled.ConversationStore.ReadForSessionAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false);
- lock (_session.InterruptLock)
+ lock (_session.TurnLock)
{
if (_session.AgentSession is null)
{
@@ -76,7 +230,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel
session,
_session.ConversationId,
spoken,
- _session.Events.RaiseDroppedTranscriptWrite,
+ Reads,
new TranscriptMarks(record.NextOrdinal, stored?.NextTurnIndex ?? 0));
if (stored is { } s)
@@ -89,88 +243,21 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel
}
}
- ///
- /// Brings the session's words back in line with store 1 before a turn after the first, when and
- /// only when someone else wrote to the conversation in between.
- ///
- /// The session of this conversation.
- /// Cancels the reads.
- private async ValueTask ResyncTranscriptAsync(AgentSession opened, CancellationToken cancellationToken)
- {
- await _session.History.DrainAsync(opened).ConfigureAwait(false);
-
- try
- {
- ConversationRecord refreshed = await RecordAsync("resync against", cancellationToken).ConfigureAwait(false);
-
- if (refreshed.NextOrdinal == _session.History.NextOrdinal(opened))
- {
- return;
- }
-
- IReadOnlyList rows = await _session.Compiled.ConversationStore.ReadForSessionAsync(
- _session.ConversationId, cancellationToken).ConfigureAwait(false);
-
- _session.History.Resync(opened, rows, refreshed.NextOrdinal);
- }
-#pragma warning disable CA1031 // A store that cannot be read never ends a conversation: the turn runs on the words the session holds.
- catch (Exception exception) when (exception is not OperationCanceledException)
-#pragma warning restore CA1031
- {
- Log.TranscriptResyncFailed(_session.Logger, _session.ConversationId, _session.State.TurnIndex, exception);
- _session.Events.RaiseFailedTranscriptResync(_session.State.TurnIndex, exception);
- }
- }
-
- ///
- /// Cuts an edit that reaches under the summary: the parent is a row the summary stands in for,
- /// or the edit takes the whole conversation. The caller asks
- /// first and comes here when it says no.
- ///
- /// The session of this conversation.
- /// The message the edit hangs off, or for the whole conversation.
- /// Cancels the cut.
- /// The turns the cut withdrew, or when nothing went.
- internal async ValueTask CutUnderSummaryAsync(
- AgentSession opened, string? parentMessageId, CancellationToken cancellationToken)
+ /// The session a conversation that carries its history runs on: one read from the stored state, or a fresh one.
+ private async ValueTask SessionFromAsync(ConversationSessionState? stored, CancellationToken cancellationToken)
{
- IConversationStore store = _session.Compiled.ConversationStore;
-
- await _session.History.DrainAsync(opened).ConfigureAwait(false);
-
- int from;
- if (parentMessageId is null)
- {
- from = 0;
- }
- else if (await store.OrdinalOfAsync(_session.ConversationId, parentMessageId, cancellationToken).ConfigureAwait(false) is { } parent)
- {
- from = parent + 1;
- }
- else
- {
- return null;
- }
-
- ConversationCut cut = await store.TruncateAsync(_session.ConversationId, from, cancellationToken).ConfigureAwait(false);
- Log.ConversationTruncated(_session.Logger, _session.ConversationId, from, _session.State.TurnIndex);
-
- ConversationRecord refreshed = await RecordAsync("edit", cancellationToken).ConfigureAwait(false);
- IReadOnlyList rows = await store.ReadForSessionAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false);
-
- _session.History.Resync(opened, rows, refreshed.NextOrdinal);
-
- return cut.Turns;
+ return stored is { Version: ConversationSessionState.CurrentVersion, Providers.Count: > 0 }
+ ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(
+ HarnessSessionState.Wrap(stored.Providers), cancellationToken: cancellationToken).ConfigureAwait(false)
+ : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
- /// Store 0's record of this conversation, which must exist for a session that already opened it.
- /// What the session is about to do with it, for the error.
- /// Cancels the read.
- private async ValueTask RecordAsync(string purpose, CancellationToken cancellationToken)
+ /// The workflow session a graph row that reuses one resumes from: the stored one, or a fresh one.
+ private async ValueTask GraphSessionFromAsync(ConversationSessionState? stored, CancellationToken cancellationToken)
{
- return await _session.Compiled.ConversationStore.GetAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false)
- ?? throw new InvalidOperationException(
- $"Store 0 holds no conversation '{_session.ConversationId}' for its own session to {purpose}.");
+ return stored is { Version: ConversationSessionState.CurrentVersion, WorkflowState: { } blob }
+ ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(blob, cancellationToken: cancellationToken).ConfigureAwait(false)
+ : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
///
@@ -179,14 +266,14 @@ private async ValueTask RecordAsync(string purpose, Cancella
/// The session.
internal AgentSession? Session()
{
- lock (_session.InterruptLock)
+ lock (_session.TurnLock)
{
return _session.AgentSession;
}
}
///
- /// Waits for every store 1 write this conversation has queued.
+ /// Waits for every store write this conversation has queued.
///
/// A task that completes when the words of the conversation are durable.
internal async Task FlushTranscriptAsync()
diff --git a/src/AgentCore.Application/Runtime/ConversationTurn.cs b/src/AgentCore.Application/Runtime/ConversationTurn.cs
index d56a694f..5f73cd3e 100644
--- a/src/AgentCore.Application/Runtime/ConversationTurn.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTurn.cs
@@ -13,7 +13,6 @@ namespace AgentCore.Application.Runtime
internal sealed record ConversationTurn(
AIAgent Agent,
AgentSession Session,
- List Request,
ChatMessage Spoken,
string? Reminder,
string StageBefore,
@@ -24,5 +23,5 @@ internal sealed record ConversationTurn(
TurnResults Results,
TurnFiles Files,
KnowledgeScope? Knowledge,
- string? MessageId);
+ ConversationTurnOrigin? Origin);
}
diff --git a/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs b/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs
new file mode 100644
index 00000000..7b0b6f9e
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs
@@ -0,0 +1,244 @@
+using System.Runtime.CompilerServices;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Transcript;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// The outermost turn layer, and the one trigger of a turn's durable write. On entry it runs the turn's edit;
+ /// when the run ends it seals the turn once: the completer decides it, the provider commits it in one append,
+ /// and the ids of that append ride the turn's last update.
+ ///
+ /// The layered agent of one entry or one stage.
+ /// Store 1, the only door to the conversation's words.
+ internal sealed class ConversationTurnAgent(AIAgent inner, AgentCoreChatHistoryProvider history) : DelegatingAIAgent(inner)
+ {
+ /// The cut of a run the host cancelled or the reader abandoned: it keeps everything yielded (W05, W06).
+ private static readonly TurnCut Stopped = new(ShownText: null, Played: null);
+
+ private readonly AgentCoreChatHistoryProvider _history = history ?? throw new ArgumentNullException(nameof(history));
+
+ ///
+ protected override async Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (Filed(options) is null)
+ {
+ return await base.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ List updates = [];
+ await foreach (AgentResponseUpdate update in RunCoreStreamingAsync(messages, session, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ if (!update.Contents.OfType().Any())
+ {
+ updates.Add(update);
+ }
+ }
+
+ return updates.ToAgentResponse();
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (Filed(options) is not { } turn)
+ {
+ await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ yield break;
+ }
+
+ IEnumerable request = await EnterAsync(turn, messages, cancellationToken).ConfigureAwait(false);
+
+ List seen = [];
+ Exception? fault = null;
+ bool hostCancelled = false;
+ Seal seal = default;
+ IAsyncEnumerator stream = base
+ .RunCoreStreamingAsync(request, session, options, cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+ try
+ {
+ while (true)
+ {
+ try
+ {
+ if (!await stream.MoveNextAsync().ConfigureAwait(false))
+ {
+ break;
+ }
+ }
+ catch (OperationCanceledException) when (turn.CutSlot.IsCut)
+ {
+ break;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ hostCancelled = true;
+ break;
+ }
+#pragma warning disable CA1031 // Section 8.7, row six: a fault above every fallback still ends in a sealed turn.
+ catch (Exception exception) when (exception is not OperationCanceledException)
+#pragma warning restore CA1031
+ {
+ fault = exception;
+ break;
+ }
+
+ seen.Add(stream.Current);
+ yield return stream.Current;
+ }
+
+ seal = await SealAsync(turn, seen, fault, hostCancelled ? Stopped : null).ConfigureAwait(false);
+ }
+ finally
+ {
+ await stream.DisposeAsync().ConfigureAwait(false);
+
+ // A reader that stops reading disposes this stream mid-run, and the turn keeps what it was
+ // yielded (design row W06). A turn already sealed seals nothing here.
+ _ = await SealAsync(turn, seen, fault, Stopped).ConfigureAwait(false);
+ }
+
+ // The host asked to stop: the turn is committed, and the host still gets its cancellation.
+ if (hostCancelled)
+ {
+ throw new OperationCanceledException(cancellationToken);
+ }
+
+ if (seal.Refused)
+ {
+ throw new ConversationTurnConflictException(
+ $"Another request saved turn {turn.Invocation.TurnIndex} of conversation '{turn.Invocation.ConversationId}' "
+ + "first, so the words of this one were not kept.");
+ }
+
+ if (seal.Ids is { } committed)
+ {
+ yield return new AgentResponseUpdate(ChatRole.Assistant, [new TurnCommittedContent(committed.UserMessageId, committed.ReplyMessageId)]);
+ }
+ }
+
+ private static FiledTurn? Filed(AgentRunOptions? options)
+ {
+ return TurnInvocation.From(options) is
+ {
+ Nested: false,
+ HistorySession: { } historySession,
+ User: { } user,
+ Completer: { } completer,
+ CutSlot: { } slot,
+ } invocation
+ ? new FiledTurn(invocation, historySession, user, completer, slot)
+ : null;
+ }
+
+ /// Runs the turn's edit, and builds the request the run gets.
+ private async ValueTask> EnterAsync(
+ FiledTurn turn, IEnumerable messages, CancellationToken cancellationToken)
+ {
+ if (turn.Invocation.Origin is { NamesParent: true } origin)
+ {
+ WithdrawnTurns? withdrawn = _history.CanTruncateFrom(turn.HistorySession, origin.ParentMessageId)
+ ? await _history.TruncateFromAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false)
+ : await _history.CutUnderSummaryAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false);
+
+ if (withdrawn is { } taken)
+ {
+ turn.Completer.Superseded(taken);
+ }
+ }
+
+ return turn.Invocation.RendersHistory
+ && TurnMessages.GraphHistory(_history.Read(turn.HistorySession)) is { } rendered
+ ? [rendered, .. messages]
+ : messages;
+ }
+
+ /// Seals the turn: decide it, write it once, and raise its events.
+ /// The turn being sealed.
+ /// Every update the run yielded.
+ /// The fault the run threw, or .
+ /// The cut to seal with when no cut reached the slot, or for none.
+ ///
+ /// The ids the commit wrote, which are when the turn was already sealed or the session
+ /// holds no conversation, and whether the store refused them.
+ ///
+ private async ValueTask SealAsync(
+ FiledTurn turn, List seen, Exception? fault, TurnCut? stopped)
+ {
+ if (!turn.CutSlot.TrySeal(out TurnCut? cut))
+ {
+ return default;
+ }
+
+ cut ??= stopped;
+
+ TurnCommit sealing = new(turn.User)
+ {
+ Seen = seen.ToAgentResponse(),
+ Disposition = ConversationTurnStream.ReadDisposition(seen),
+ Cut = cut,
+ CallerFacing = !turn.Invocation.CarriesHistory,
+ UserMessageId = turn.Invocation.Origin?.MessageId,
+ };
+
+ TurnCommit commit = await turn.Completer.CompleteAsync(sealing, fault).ConfigureAwait(false);
+
+ TurnWrite? written;
+ lock (turn.CutSlot.Gate)
+ {
+ written = _history.CommitTurn(turn.HistorySession, commit);
+ }
+
+ // Another session of the conversation may have saved this turn first. The store's answer decides
+ // whether the turn is published at all, so it is awaited here, outside the lock. A cut that arrives
+ // meanwhile waits as a late cut, exactly as one between the seal and the commit does.
+ if (written is not null && await turn.Completer.RefusedAsync(written.Refused).ConfigureAwait(false))
+ {
+ return new Seal(null, Refused: true);
+ }
+
+ // A session that holds no conversation writes no rows, so the words are read off what the rows would be.
+ (string UserMessageId, string? ReplyMessageId)? ids = written?.Ids;
+
+ string spoken = written?.Spoken ?? TurnWords.Spoken(TurnWords.Compose(commit, []));
+
+ lock (turn.CutSlot.Gate)
+ {
+ TurnCut? late = turn.CutSlot.Commit(out TurnCut? recut);
+ turn.Completer.Committed(ids, spoken, late, recut);
+ }
+
+ await turn.Completer.FinishAsync().ConfigureAwait(false);
+
+ return new Seal(ids, Refused: false);
+ }
+
+ /// What sealing a turn wrote, and whether the store refused it.
+ private readonly record struct Seal((string UserMessageId, string? ReplyMessageId)? Ids, bool Refused);
+
+ private sealed record FiledTurn(
+ TurnInvocation Invocation,
+ AgentSession HistorySession,
+ ChatMessage User,
+ ITurnCompleter Completer,
+ TurnCutSlot CutSlot);
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs b/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs
index f9e03a88..392d7db1 100644
--- a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs
@@ -1,14 +1,19 @@
+using AgentCore.Application.Diagnostics;
using AgentCore.Domain;
using AgentCore.Domain.Audit;
namespace AgentCore.Application.Runtime
{
///
- /// The publish half of a turn: appends the transcript, writes the turn's events, and tears the
- /// conversation down when it is over. decides the turn; this commits it.
+ /// The publish half of a turn: writes the turn's events once its words are committed, and tears the
+ /// conversation down when it is over. decides the turn; this publishes it.
///
internal sealed class ConversationTurnCommit
{
+ /// The failure a refused turn's span ends with.
+ internal const string RefusedReason =
+ "the store refused the turn's words: another session of the conversation saved the same turn first.";
+
private readonly ConversationSession _session;
internal ConversationTurnCommit(ConversationSession session)
@@ -17,50 +22,76 @@ internal ConversationTurnCommit(ConversationSession session)
_session = session;
}
- internal TurnResult Publish(
- ConversationTurn turn,
- ReplyOutcome outcome,
- TranscriptEntry addition,
- TurnResult result,
- ConversationInterruptionTracker.Interruption? interruption)
+ ///
+ /// Waits for the store's answer to a turn's append, however long it takes, and marks the session refused when
+ /// the store refused it.
+ ///
+ /// Completes with when the store refused the append. It never faults.
+ /// when the store refused the turn.
+ internal async ValueTask RefusedAsync(Task refused)
{
- lock (_session.InterruptLock)
+ if (!await refused.ConfigureAwait(false))
{
- _session.LastReplyMessageId = _session.SessionCarriesHistory
- ? _session.History.AppendTurn(
- turn.Session, [turn.Spoken, .. addition.Written], _session.Snapshot(), turn.MessageId)
- : _session.History.AppendCallerFacingTurn(
- turn.Session, turn.Spoken, addition.Heard, _session.Snapshot(), turn.MessageId);
+ return false;
+ }
- Guid completedEventId = _session.Events.WriteTurnEvents(result, outcome.SpokenReply, outcome.ToolFault);
+ _session.Ledger.Reads.MarkRefused();
+ return true;
+ }
- _session.AmendableEventId = outcome.InterruptedAfter is null ? completedEventId : null;
+ /// Publishes one committed turn. Runs under the turn lock, right after the commit.
+ /// What the commit wrote the user's message and the last message under, or .
+ /// The words the turn's rows say. An uncut turn's turn.completed and a cut turn's
+ /// reply.interrupted hash them, so the verify query rebuilds exactly what the chain proves.
+ /// What the turn said and what the caller heard.
+ /// The finished turn.
+ /// The cut the turn was sealed with, or .
+ ///
+ /// A barge-in that reached the turn after its seal, to amend the reply just written, or .
+ /// When is set, it is a recut that replaces that cut.
+ ///
+ /// The turn as published, amended when landed.
+ internal TurnResult Publish(
+ (string UserMessageId, string? ReplyMessageId)? ids,
+ string spoken,
+ ReplyOutcome outcome,
+ TurnResult result,
+ TurnCut? cut,
+ TurnCut? late)
+ {
+ _session.LastReplyMessageId = ids is { } written ? written.ReplyMessageId ?? written.UserMessageId : null;
- _session.LastTurn = result;
+ if (outcome.Fault is { } fault && outcome.IsToolFault)
+ {
+ Log.ToolBudgetSpent(_session.Logger, result.ConversationId, result.TurnIndex, fault);
+ }
- if (interruption is null
- && _session.Interruption is { } late
- && _session.Interruptions.AmendLastTurn(late)
- && _session.LastTurn is { } amended)
- {
- result = amended;
- }
+ // A cut turn's rows hold what was shown, so its turn.completed proves what the model produced instead.
+ Guid completedEventId = _session.Events.WriteTurnEvents(
+ result, cut is null ? spoken : outcome.GeneratedText, spoken, outcome.ToolFault, cut?.Played);
- _session.Interruption = null;
+ _session.AmendableEventId = completedEventId;
+ _session.AmendableText = spoken;
- _session.RunCancellation = null;
+ _session.LastTurn = result;
- _session.Interruptions.RunIsAudible = false;
+ if (late is not null
+ && _session.Cuts.AmendTurn(result.TurnIndex, late, recut: cut is not null)
+ && _session.LastTurn is { } amended)
+ {
+ result = amended;
}
return result;
}
- internal async Task EndConversationIfOverAsync(DateTimeOffset endedAt, string stageAfter)
+ /// Closes the chain of a conversation the turn left in a terminal stage, and releases what it held.
+ /// The stage the turn left the conversation in.
+ internal async Task EndConversationIfOverAsync(string stageAfter)
{
if (_session.IsComplete)
{
- _ = _session.Events.EndConversation(ConversationEndReason.AgentCompleted, endedAt, stageAfter);
+ _ = _session.Events.EndConversation(ConversationEndReason.AgentCompleted, _session.Time.GetUtcNow(), stageAfter);
}
if (!_session.IsComplete && !_session.Events.HasEnded)
diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs b/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs
index 62ce8a8b..30964b09 100644
--- a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs
@@ -1,6 +1,7 @@
-using System.Text.Json;
+using System.Diagnostics;
+using System.Globalization;
using AgentCore.Application.Diagnostics;
-using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Transcript;
using AgentCore.Domain;
using AgentCore.Domain.Audit;
using Microsoft.Agents.AI;
@@ -8,91 +9,111 @@
namespace AgentCore.Application.Runtime
{
- internal sealed class ConversationTurnCompletion
+ ///
+ /// The conversation's side of one turn's seal: it decides the turn after its run, and publishes it once
+ /// committed the words. One instance serves one turn.
+ ///
+ internal sealed class ConversationTurnCompletion : ITurnCompleter
{
private readonly ConversationSession _session;
+ private readonly ConversationTurn _turn;
+
private readonly ConversationTurnCommit _commit;
- internal ConversationTurnCompletion(ConversationSession session)
+ private ReplyOutcome? _outcome;
+
+ private TurnResult? _decided;
+
+ private TurnCut? _cut;
+
+ /// Creates the completion of one turn.
+ /// The conversation.
+ /// The turn it completes.
+ internal ConversationTurnCompletion(ConversationSession session, ConversationTurn turn)
{
ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(turn);
_session = session;
+ _turn = turn;
_commit = new ConversationTurnCommit(session);
}
+ /// Gets the finished turn, or until the turn is published.
+ internal TurnResult? Result { get; private set; }
+
+ ///
+ public void Superseded(WithdrawnTurns withdrawn)
+ {
+ _session.Clarifications.Withdraw();
+
+ _ = _session.Events.Raise(
+ ConversationEventKind.TurnSuperseded,
+ _session.Time.GetUtcNow(),
+ _session.State.TurnIndex,
+ payload: new Dictionary(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.WithdrewFromTurnIndex] =
+ withdrawn.First.ToString(CultureInfo.InvariantCulture),
+ [AuditPayloadKeys.WithdrewThroughTurnIndex] =
+ withdrawn.Last.ToString(CultureInfo.InvariantCulture),
+ });
+ }
+
/// Runs every writer, then lets the machine pick the stage of the next turn.
- /// The turn that just spoke.
- /// What the agent answered.
- ///
- /// The message of the fault that threw out of the run, or when nothing
- /// threw. Section 8.7, row six.
- ///
- /// Cancels the extractor call.
- /// The finished turn.
- ///
- /// What and reported about
- /// this turn, or when neither layer marked it. A flagged turn skips the
+ ///
+ /// The turn as the layer saw it end. Its disposition is what and
+ /// reported. A flagged turn skips the
/// extractor: the words moderation flagged are its only input, and a slot filled from them would
/// carry the flagged content into the state document and into every later prompt. Everything else
/// about a refused turn is ordinary, so the refusal enters the transcript, the stage machine
/// advances, and turn.completed proves the refusal under
/// .
///
- internal async Task CompleteTurnAsync(
- ConversationTurn turn,
- AgentResponse response,
- string? toolFault,
- TurnDisposition? disposition,
- CancellationToken cancellationToken)
+ ///
+ /// The fault that threw out of the run, or when nothing threw. Section 8.7,
+ /// row six.
+ ///
+ /// The commit, with the state after the stage advance and the line the record holds.
+ public async ValueTask CompleteAsync(TurnCommit sealing, Exception? fault)
{
- // Sweep drain state for tool calls whose round never reached the client's drain: the
- // round that ends a turn attaches nothing, so turn-scoped records would otherwise
- // leave entries sitting on the shared clients.
+ ArgumentNullException.ThrowIfNull(sealing);
+
+ Activity.Current = _turn.Activity;
+
+ ConversationTurn turn = _turn;
+
+ AgentResponse response = sealing.Seen ?? new AgentResponse();
+
+ TurnDisposition? disposition = sealing.Disposition;
+
+ _cut = sealing.Cut;
+
turn.Results.Sweep();
- // Calls the model named that no tool answered never reach the invoking client, so no
- // per-call state names them. The turn's own record does: every id the model gave minus
- // every id a tool ran under. Reported here, at the same turn end that sweeps the drain.
ReportUndeclaredToolCalls(turn, response);
- ConversationInterruptionTracker.Interruption? interruption = _session.Interruptions.CurrentInterruption();
-
- // The moderation facts of this turn, raised before the turn's own events so the flag still
- // takes the lower ordinal. The verdict is known before the model runs, which is the one rule
- // that separates prompt.flagged from reply.interrupted: it amends nothing.
bool refused = disposition?.Moderation is ModerationOutcome.Flagged;
- _session.Events.RaiseModeration(turn.Index, disposition);
- ReplyOutcome outcome = ResolveReply(turn, response, interruption, disposition, toolFault);
- TranscriptEntry addition = TranscriptAddition(response, outcome);
+ _session.Events.RaiseModeration(turn.Index, disposition);
- // The per-turn writers run from here, in the order the constructor documents: tool results
- // first.
+ ReplyOutcome outcome = TurnReplyResolution.Resolve(_session, turn, response, _cut, disposition, fault, sealing.CallerFacing);
_session.Writers.ApplyToolResults(response.Messages);
- // A refused turn runs no extractor. The words moderation flagged are the extractor's only
- // input, so a slot filled from them would carry the flagged content into the state document
- // and into every later prompt. The refusal itself says nothing worth extracting either, and
- // the extractor costs a model call, so this also spends nothing on a turn nobody answered.
- // A pending turn runs none either: the run suspended instead of answering, so there is
- // nothing decided to read.
string? extractionFailure = refused || outcome.Approvals.Count > 0
? null
- : await ExtractAsync(turn, response, cancellationToken).ConfigureAwait(false);
+ : await ExtractAsync(turn, response).ConfigureAwait(false);
- // The clock fields write next. The clock comes from the injected provider, so a test owns
- // it. The same read stamps every audit event of this turn.
DateTimeOffset endedAt = _session.Time.GetUtcNow();
+
_session.State.TurnIndex++;
_session.State.ConversationDurationSeconds = (endedAt - _session.StartedAt).TotalSeconds;
- // The counters write last.
_ = _session.Counters.Apply(_session.State);
string stageAfter = AdvanceStage(turn);
- TurnResult result = new(
+ _decided = new(
_session.ConversationId,
turn.Index,
turn.StageBefore,
@@ -106,146 +127,109 @@ internal async Task CompleteTurnAsync(
{
Approvals = outcome.Approvals,
};
+ _outcome = outcome;
- // A graph row that reuses its session persists it here, before the commit below reads
- // it into store 0: the next turn resumes from these checkpoints, and a resumed conversation
- // rebuilds them. Rows 1 and 2 need nothing — their session outlives the conversation.
if (_session.ReusesGraphSession && _session.GraphSession is { } shared)
{
- _session.GraphBlob = await turn.Agent.SerializeSessionAsync(shared, cancellationToken: cancellationToken).ConfigureAwait(false);
+ _session.GraphBlob = await turn.Agent.SerializeSessionAsync(shared).ConfigureAwait(false);
}
- result = _commit.Publish(turn, outcome, addition, result, interruption);
-
- AgentCoreTelemetry.EndTurn(
- turn.Activity,
- _session.Time.GetElapsedTime(turn.StartedAt),
- Outcome(result.Failure, result.InterruptedAfter),
- stageAfter,
- result.Failure);
-
- await _commit.EndConversationIfOverAsync(endedAt, stageAfter).ConfigureAwait(false);
-
- return result;
+ return sealing with
+ {
+ Completed = outcome.InterruptedAfter is null && outcome.Failure is null,
+ Reply = outcome.Reply,
+ State = _session.Snapshot(),
+ };
}
- private ReplyOutcome ResolveReply(
- ConversationTurn turn,
- AgentResponse response,
- ConversationInterruptionTracker.Interruption? interruption,
- TurnDisposition? disposition,
- string? toolFault)
+ ///
+ public async ValueTask RefusedAsync(Task refused)
{
- // R1 reaches the turn through the fallback layer, and a fault above every chat client — a
- // graph that matched no edge — still reaches the catch in the run methods. Both are the
- // same row-six fact.
- toolFault ??= disposition is { Fallback: FallbackCause.Faulted, FallbackReason: { } caught }
- ? caught
- : null;
-
- string reply = SpokenReply.From(response.Messages);
- string spokenReply = reply;
- TimeSpan? interruptedAfter = null;
- string? failure = toolFault is null ? null : ConversationSession.ToolFailureReason + " " + toolFault;
-
- // The tool calls still waiting on a human answer. A pending turn is not a failure: the
- // run suspended instead of answering, so the empty-reply substitution below is skipped,
- // the extractor has nothing decided to read, and the requests ride TurnResult to the host.
- List approvals = ApprovalRequestsOf(response);
-
- if (interruption is { } cut)
+ if (!await _commit.RefusedAsync(refused).ConfigureAwait(false))
{
- // Item 6a. The record holds the text the caller heard, not the text the model produced.
- // A trailing space is not speech, so it is trimmed: pipecat pins the same rule in its
- // aggregator test, where the model sent "Hello " and the record holds "Hello".
- reply = cut.HeardText.Trim();
- interruptedAfter = cut.PlayedDuration;
- }
- else if (approvals.Count == 0 && (failure is not null
- || string.IsNullOrWhiteSpace(reply)
- || disposition?.Fallback is FallbackCause.EmptyReply))
- {
- // Section 8.7, last row. A quiet run is silence on a voice conversation, so an empty reply is a
- // failure even though nothing threw.
- failure ??= ConversationSession.EmptyReplyReason;
- reply = _session.Compiled.FallbackReply;
- spokenReply = reply;
+ return false;
}
- // Section 8.7, last row, raised once for the turn. It is diagnostic only, so it takes no
- // ordinal and no row records it; the turn.completed event of this same turn carries the
- // fallback the caller actually heard. The tool fault of row six is raised in
- // WriteTurnEvents instead, because the chain stores that one and its row is stamped with
- // the same endedAt every other event of the turn carries.
- if (toolFault is null && failure is not null)
- {
- _session.Events.RaiseDiagnostic(ConversationEventKind.EmptyReply, _session.Time.GetUtcNow(), turn.Index);
- }
+ TurnRefusals.Raise(_session, _turn.Index, TurnRefusals.Conflict);
- return new ReplyOutcome(reply, spokenReply, failure, toolFault, interruptedAfter, approvals);
+ AgentCoreTelemetry.EndTurn(
+ _turn.Activity,
+ _session.Time.GetElapsedTime(_turn.StartedAt),
+ AgentCoreTelemetry.OutcomeFailed,
+ _turn.StageBefore,
+ ConversationTurnCommit.RefusedReason);
+
+ return true;
}
- // Built before the writers run and committed at the end of the turn, in one lock with the rest
- // of what a late barge-in may still amend: the transcript span, the ordinal the amendment must
- // reference, and LastTurn itself. Nothing in between reads the transcript — the extractor reads
- // the finished turn, and the writers read the state document — so building it early and
- // committing it once costs nothing.
- private TranscriptEntry TranscriptAddition(AgentResponse response, ReplyOutcome outcome)
+ ///
+ public void Committed((string UserMessageId, string? ReplyMessageId)? ids, string spoken, TurnCut? late, TurnCut? recut)
{
- // A barge-in inside the first 100 ms leaves no heard text, and an empty assistant message
- // teaches the model nothing. pipecat and livekit both guard this. Both row shapes read this
- // one value, so what counts as words the caller heard is decided once.
- ChatMessage? heard = outcome.Reply.Length > 0 ? new ChatMessage(ChatRole.Assistant, outcome.Reply) : null;
-
- // Rows 3 and 4 record the caller-facing turn alone, so `written` stays empty for them: a
- // node's tool pair and a node's line to the next node are neither said nor heard, and the
- // commit takes the heard reply straight.
- if (!_session.SessionCarriesHistory)
+ if (_outcome is not { } outcome || _decided is not { } decided)
{
- return new TranscriptEntry(heard, []);
+ throw new InvalidOperationException("The turn was committed before it was completed.");
}
- if (outcome.InterruptedAfter is null && outcome.Failure is null)
- {
- return new TranscriptEntry(heard, [.. response.Messages]);
- }
+ // A barge-in after the seal amends the reply just written, unless a cut already shaped it; a recut
+ // amends the reply that cut shaped.
+ Result = _commit.Publish(ids, spoken, outcome, decided, _cut, _cut is null ? late : recut);
+ }
- // The transcript holds what the caller heard. A run that stopped mid-round would
- // otherwise leave a tool call with no result behind, and the next turn would send
- // it. So the pairs that finished are kept and only an unpaired call is dropped: a
- // side effect that ran must stay visible to the next turn. livekit/agents fixed the
- // same defect in issue 3702.
- List written = [.. TurnMessages.FinishedToolMessages(response.Messages)];
- if (heard is not null)
+ ///
+ public async ValueTask FinishAsync()
+ {
+ if (Result is not { } result)
{
- written.Add(heard);
+ throw new InvalidOperationException("The turn was finished before it was committed.");
}
- return new TranscriptEntry(heard, written);
+ AgentCoreTelemetry.EndTurn(
+ _turn.Activity,
+ _session.Time.GetElapsedTime(_turn.StartedAt),
+ Outcome(result.Failure, result.Cut),
+ result.StageAfter,
+ result.Failure,
+ ErrorType(result.Failure));
+
+ await _commit.EndConversationIfOverAsync(result.StageAfter).ConfigureAwait(false);
+ }
+
+ /// Reads the error.type value a failed turn's span carries. The metric point keeps its outcome alone.
+ /// The reason the record itself holds, or when the turn answered.
+ ///
+ /// The exception's own type when caught one, the closed
+ /// empty-reply token when it did not, or when the turn did not fail. Never
+ /// the exception's message: that can carry caller or model text, and
+ /// already holds it for whoever reads itself.
+ ///
+ private string? ErrorType(string? failure)
+ {
+ return (failure, _outcome?.Fault) switch
+ {
+ (null, _) => null,
+ (_, { } fault) => fault.GetType().FullName,
+ _ => AgentCoreTelemetry.FailureEmptyReply,
+ };
}
// The extractor's deadline is here and not on the whole turn, because the raise of the turn's
// events and the stage advance must always run.
- private async Task ExtractAsync(ConversationTurn turn, AgentResponse response, CancellationToken cancellationToken)
+ private async Task ExtractAsync(ConversationTurn turn, AgentResponse response)
{
string? extractionFailure;
- using CancellationTokenSource deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- deadline.CancelAfter(ConversationSession.TurnCompletionTimeout);
+ using CancellationTokenSource deadline = new(ConversationSession.TurnCompletionTimeout, _session.Time);
try
{
extractionFailure = await _session.Writers.ExtractAsync(turn, response, deadline.Token).ConfigureAwait(false);
}
- catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (deadline.IsCancellationRequested)
{
extractionFailure = ConversationSession.ExtractionTimedOutReason;
}
if (extractionFailure is not null)
{
- // Section 8.7, row two: leave the slots unchanged, report once for the turn, and continue
- // the conversation. State extraction must never drop a conversation. The reason rides on the event
- // because the line an operator reads is the same one the turn loop used to write itself.
_session.Events.RaiseDiagnostic(
ConversationEventKind.ExtractionFailed,
_session.Time.GetUtcNow(),
@@ -286,31 +270,6 @@ private static string Outcome(string? failure, TimeSpan? interruptedAfter)
};
}
- /// Reads the approval requests one finished turn still waits on.
- /// What the agent answered.
- /// One entry per request content, oldest first; empty when the turn asked nothing.
- private static List ApprovalRequestsOf(AgentResponse response)
- {
- List approvals = [];
- foreach (ToolApprovalRequestContent request in response.Messages
- .SelectMany(message => message.Contents)
- .OfType())
- {
- if (request.ToolCall is not FunctionCallContent call)
- {
- continue;
- }
-
- approvals.Add(new PendingApproval(
- request.RequestId,
- call.Name,
- JsonSerializer.SerializeToElement(
- call.Arguments ?? new Dictionary(StringComparer.Ordinal))));
- }
-
- return approvals;
- }
-
/// Reports calls the model named that no tool answered.
/// The turn that just ran.
/// What the agent answered.
diff --git a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs
index 92ed6453..177f5825 100644
--- a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs
@@ -1,12 +1,8 @@
using System.Diagnostics;
-using System.Globalization;
using AgentCore.Application.Diagnostics;
using AgentCore.Application.Knowledge;
using AgentCore.Application.Runtime.Turn;
using AgentCore.Application.State;
-using AgentCore.Application.Transcript;
-using AgentCore.Domain;
-using AgentCore.Domain.Audit;
using AgentCore.Domain.Knowledge;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -49,95 +45,55 @@ internal void SetTimeZone(TimeZoneInfo zone)
_timeZone = zone;
}
- /// Runs one turn of the conversation against the session the conversation holds.
- /// What the caller said or answered.
- /// Where the turn hangs, or null for a caller that does not say.
- /// Cancels the turn.
- /// What the turn did.
- internal async Task RunTurnCoreAsync(
- ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
+ ///
+ /// Admits one turn: refuses a terminal or disposed conversation, and waits for a running turn of this
+ /// conversation to end, up to counted from .
+ /// follows. The turn's edit runs later, in .
+ ///
+ /// The at which the turn began to wait for the conversation.
+ /// Cancels the wait.
+ /// The conversation was disposed.
+ /// The conversation is terminal.
+ /// Another turn still ran past the limit.
+ internal async Task AdmitTurnAsync(long started, CancellationToken cancellationToken)
{
- AgentSession session = await _session.Ledger.OpenSessionAsync(cancellationToken).ConfigureAwait(false);
-
- await AdmitTurnAsync(session, origin, cancellationToken).ConfigureAwait(false);
- ConversationTurn turn = BeginTurn(userInput, session, origin);
-
- CancellationTokenSource cancellation = _session.Interruptions.StartRun(cancellationToken);
+ RefuseIfTerminal();
+ await _session.Cuts.EnterTurnAsync(started, cancellationToken).ConfigureAwait(false);
try
{
- TurnInvocation invocation = TurnInvocationOf(turn);
-
- AgentSession runSession = await OpenRunAsync(turn, cancellation.Token).ConfigureAwait(false);
-
- TurnRegistry.Set(runSession, invocation);
-
- AgentResponse response;
- string? toolFault = null;
-
- try
- {
- response = await turn.Agent
- .RunAsync(
- turn.Request,
- runSession,
- invocation.RunOptions(),
- cancellationToken: cancellation.Token)
- .ConfigureAwait(false);
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- toolFault = exception.Message;
- response = new AgentResponse();
- }
-
- return await _session.Completion.CompleteTurnAsync(turn, response, toolFault, ConversationTurnStream.ReadDisposition(response), cancellationToken)
- .ConfigureAwait(false);
+ RefuseIfTerminal();
}
- finally
+ catch
{
- _session.Interruptions.EndRun(cancellation);
- turn.Activity?.Dispose();
+ _session.Cuts.ReleaseTurn();
+ throw;
}
+
+ _session.Clarifications.BeginTurn();
}
- ///
- /// Admits one turn: refuses a terminal or running conversation, then withdraws whatever the turn
- /// replaces. follows, from the same frame.
- ///
- /// The session of this conversation.
- /// Where the turn hangs, or null for a caller that does not say.
- /// Cancels the withdrawal.
- internal async ValueTask AdmitTurnAsync(AgentSession session, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
+ private void RefuseIfTerminal()
{
- if (_session.IsComplete)
+ if (!_session.Ledger.Reads.Diverged || _session.Events.HasEnded)
{
- throw new InvalidOperationException(
- $"The conversation '{_session.ConversationId}' reached the terminal stage '{_session.Stage}', so it runs no further turn.");
+ RefuseIfComplete();
}
+ }
- if (!_session.Interruptions.TryEnterTurn())
+ /// Refuses a turn on a conversation that reached a terminal stage.
+ /// The conversation reached a terminal stage.
+ internal void RefuseIfComplete()
+ {
+ if (_session.IsComplete)
{
throw new InvalidOperationException(
- $"A turn of the conversation '{_session.ConversationId}' is still running. One conversation runs one turn at a time.");
- }
-
- try
- {
- _session.Clarifications.BeginTurn();
-
- await WithdrawSupersededAsync(session, origin, cancellationToken).ConfigureAwait(false);
- }
- catch
- {
- _session.Interruptions.ReleaseTurn();
-
- throw;
+ $"The conversation '{_session.ConversationId}' reached the terminal stage '{_session.Stage}', so it runs no further turn.");
}
}
///
- /// Picks the agent, builds the model input, and takes the turn admitted.
+ /// Picks the agent and takes the turn admitted.
/// Synchronous on purpose: the turn span it opens must be the ambient activity of the caller's
/// frame, and an async method hands no back.
///
@@ -150,6 +106,8 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
Activity? activity = null;
try
{
+ _session.Lifetime.TouchWorkspace();
+
AIAgent agent = ResolveAgent();
string? reminder = _session.Policy is null ? null : UnfilledSlotReminder.Build(_session.State, _session.Policy.CurrentStage);
@@ -158,14 +116,6 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
_session.History.BeginTurn(session, _session.State.TurnIndex);
- List request = [spoken];
- if (!_session.SessionCarriesHistory
- && !_session.ReusesGraphSession
- && TurnMessages.GraphHistory(_session.History.Read(session)) is { } rendered)
- {
- request = [rendered, spoken];
- }
-
activity = AgentCoreTelemetry.StartTurn(_session.ConversationId, _session.State.TurnIndex, _session.State.Stage);
KnowledgeScope? knowledge = StateKnowledgeScope.Compose(
@@ -179,7 +129,6 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
return new ConversationTurn(
agent,
session,
- request,
spoken,
reminder,
_session.State.Stage,
@@ -190,50 +139,18 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
new TurnResults(),
new TurnFiles(),
knowledge,
- origin?.MessageId);
+ origin);
}
catch
{
activity?.Dispose();
- _session.Interruptions.ReleaseTurn();
+ _session.Cuts.ReleaseTurn();
throw;
}
}
- /// Takes back everything the conversation said after the message this turn hangs off.
- internal async ValueTask WithdrawSupersededAsync(AgentSession session, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
- {
- if (origin is not { NamesParent: true })
- {
- return;
- }
-
- WithdrawnTurns? cut = _session.History.CanTruncateFrom(session, origin.ParentMessageId)
- ? _session.History.TruncateFrom(session, origin.ParentMessageId)
- : await _session.Ledger.CutUnderSummaryAsync(session, origin.ParentMessageId, cancellationToken).ConfigureAwait(false);
-
- if (cut is not { } withdrawn)
- {
- return;
- }
-
- _session.Clarifications.Withdraw();
-
- _ = _session.Events.Raise(
- ConversationEventKind.TurnSuperseded,
- _session.Time.GetUtcNow(),
- _session.State.TurnIndex,
- payload: new Dictionary(StringComparer.Ordinal)
- {
- [AuditPayloadKeys.WithdrewFromTurnIndex] =
- withdrawn.First.ToString(CultureInfo.InvariantCulture),
- [AuditPayloadKeys.WithdrewThroughTurnIndex] =
- withdrawn.Last.ToString(CultureInfo.InvariantCulture),
- });
- }
-
/// Picks the agent that speaks this turn.
/// The agent.
internal AIAgent ResolveAgent()
@@ -274,10 +191,12 @@ internal async ValueTask OpenRunAsync(ConversationTurn turn, Cance
return await turn.Agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
- /// Builds the invocation one turn hands its tools.
+ /// Builds the invocation one turn hands its layers and its tools.
/// The turn about to run.
- /// Everything a tool of this turn may need, as one explicit value.
- internal TurnInvocation TurnInvocationOf(ConversationTurn turn)
+ /// The conversation's side of the turn's seal.
+ /// Where a cut of the turn waits for the seal.
+ /// Everything the seal and a tool of this turn may need, as one explicit value.
+ internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter completer, TurnCutSlot slot)
{
return new()
{
@@ -299,6 +218,13 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn)
Tools = _delegatedTools?.Tools,
ToolsFor = _delegatedTools?.ToolId,
State = _session.State,
+ HistorySession = turn.Session,
+ User = turn.Spoken,
+ Origin = turn.Origin,
+ RendersHistory = !_session.SessionCarriesHistory && !_session.ReusesGraphSession,
+ Completer = completer,
+ CutSlot = slot,
+ Notices = new TurnNotices(),
};
}
}
diff --git a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs b/src/AgentCore.Application/Runtime/ConversationTurnStream.cs
index f00624a1..52a09ae7 100644
--- a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs
+++ b/src/AgentCore.Application/Runtime/ConversationTurnStream.cs
@@ -1,5 +1,10 @@
+using System.Diagnostics;
using System.Runtime.CompilerServices;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Ports;
using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Transcript;
+using AgentCore.Domain;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -27,43 +32,139 @@ internal IAsyncEnumerable RunTurnStreamingCoreAsync(
{
ArgumentNullException.ThrowIfNull(userInput);
- return RunTurnStreamingIteratorAsync(userInput, origin, cancellationToken);
+ return RunTurnStreamingIteratorAsync(userInput, origin, result: null, cancellationToken);
+ }
+
+ /// Runs one turn to its end over the streaming core, and returns what it did.
+ /// What the caller said or answered.
+ /// Where the turn hangs, or null for a caller that does not say.
+ /// Cancels the turn.
+ /// What the turn did.
+ internal async Task RunTurnCoreAsync(
+ ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(userInput);
+
+ StrongBox result = new();
+
+ await foreach (ChatResponseUpdate _ in RunTurnStreamingIteratorAsync(userInput, origin, result, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ }
+
+ return result.Value!;
+ }
+
+ /// Starts one turn: the turn is admitted and filed, and reaches it.
+ /// What the caller said or answered.
+ /// Where the turn hangs, or null for a caller that does not say.
+ /// Cancels the turn, from the start through its last update.
+ /// The turn's index and its reply. The reply runs only as it is enumerated.
+ internal async Task StartTurnAsync(
+ ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(userInput);
+
+ (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false);
+
+ return new TurnRun(
+ turn.Index,
+ RunAsync(turn, slot, result: null, new StrongBox()),
+ abandon: () => AbandonRun(turn, slot));
}
/// Streams one turn. The caller checks the arguments.
+ /// What the caller said or answered.
+ /// Where the turn hangs, or null for a caller that does not say.
+ /// Receives what the turn did, for a caller that collects the turn, or .
+ /// Cancels the model calls.
private async IAsyncEnumerable RunTurnStreamingIteratorAsync(
ChatMessage userInput,
ConversationTurnOrigin? origin,
+ StrongBox? result,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
- AgentSession session = await _session.Ledger.OpenSessionAsync(cancellationToken).ConfigureAwait(false);
+ (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false);
+
+ await foreach (ChatResponseUpdate update in RunAsync(turn, slot, result, new StrongBox()).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
- await _session.Runner.AdmitTurnAsync(session, origin, cancellationToken).ConfigureAwait(false);
- ConversationTurn turn = _session.Runner.BeginTurn(userInput, session, origin);
+ private async Task<(ConversationTurn Turn, TurnCutSlot Slot)> StartAsync(
+ ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken)
+ {
+ long started = _session.Time.GetTimestamp();
- // A streaming turn becomes audible only once it hands the host its first piece of content.
- // Until then nothing of it has reached the caller, and a barge-in belongs elsewhere.
- CancellationTokenSource cancellation = _session.Interruptions.StartRun(cancellationToken);
+ // The mark comes before every read: a turn of another session still filing its words holds it, and the
+ // reads below must see those words and the turn index after them.
+ await _session.Busy.EnterAsync(cancellationToken).ConfigureAwait(false);
try
{
- List updates = [];
- string? toolFault = null;
+ (AgentSession session, TranscriptCatchUp? catchUp) = await _session.Ledger.OpenForTurnAsync(cancellationToken).ConfigureAwait(false);
- TurnInvocation invocation = _session.Runner.TurnInvocationOf(turn) with { Notices = new TurnNotices() };
- AgentSession runSession = await _session.Runner.OpenRunAsync(turn, cancellation.Token).ConfigureAwait(false);
+ // A turn another door started on this session waits here; the catch-up below reads what it committed.
+ await _session.Runner.AdmitTurnAsync(started, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _session.Ledger.CatchUpAsync(session, catchUp, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ _session.Cuts.ReleaseTurn();
+ throw;
+ }
+
+ ConversationTurn turn = _session.Runner.BeginTurn(userInput, _session.Ledger.Session() ?? session, origin);
+
+ return (turn, _session.Cuts.StartRun(turn.Index, cancellationToken));
+ }
+ catch
+ {
+ _ = _session.Busy.Exit();
+ throw;
+ }
+ }
+
+ /// Runs one started turn to its seal, and frees the conversation for the next.
+ /// The turn began.
+ /// Where a cut of the turn waits for the seal.
+ /// Receives what the turn did, for a caller that collects the turn, or .
+ /// Set once the reply is enumerated. A second enumeration would end the run twice.
+ private async IAsyncEnumerable RunAsync(
+ ConversationTurn turn, TurnCutSlot slot, StrongBox? result, StrongBox enumerated)
+ {
+ if (Interlocked.Exchange(ref enumerated.Value, 1) != 0)
+ {
+ throw new InvalidOperationException(
+ $"The reply of turn {turn.Index} of the conversation '{_session.ConversationId}' is already being read.");
+ }
+
+ // The span opened in StartAsync's frame; an async method hands no Activity.Current back.
+ if (turn.Activity is { } span)
+ {
+ Activity.Current = span;
+ }
+
+ try
+ {
+ ConversationTurnCompletion completion = new(_session, turn);
+ TurnInvocation invocation = _session.Runner.TurnInvocationOf(turn, completion, slot);
+ AgentSession runSession = await _session.Runner.OpenRunAsync(turn, slot.Token).ConfigureAwait(false);
TurnRegistry.Set(runSession, invocation);
IAsyncEnumerable runStream = turn.Agent
.RunStreamingAsync(
- turn.Request,
+ [turn.Spoken],
runSession,
invocation.RunOptions(),
- cancellationToken: cancellation.Token);
+ cancellationToken: slot.Token);
IAsyncEnumerator stream = TurnUpdateMerge
- .RunAsync(runStream, invocation.Notices, cancellation.Token)
- .GetAsyncEnumerator(cancellation.Token);
+ .RunAsync(runStream, invocation.Notices!, slot.Token)
+ .GetAsyncEnumerator(slot.Token);
try
{
@@ -80,32 +181,25 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync
update = stream.Current;
}
- catch (OperationCanceledException) when (_session.Interruptions.CurrentInterruption() is not null)
+ catch (OperationCanceledException) when (slot.IsCut)
{
break;
}
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- toolFault = exception.Message;
-
- break;
- }
- if (update.Contents.OfType().Any())
+ // Rides through like a notice: it carries no text of its own, so a caller
+ // that reads only IsOutput-gated content never sees it unless it asks.
+ if (update.Contents.OfType().Any()
+ || update.Contents.OfType().Any())
{
yield return update.AsChatResponseUpdate();
continue;
}
- updates.Add(update);
-
ChatResponseUpdate content = update.AsChatResponseUpdate();
-
- if (TurnMessages.CarriesContent(content) && Speaks(update))
- {
- _session.Interruptions.RunIsAudible = true;
+ if (TurnMessages.CarriesContent(content) && IsOutput(update))
+ {
yield return content;
}
}
@@ -115,23 +209,53 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync
await stream.DisposeAsync().ConfigureAwait(false);
}
- _ = await _session.Completion.CompleteTurnAsync(
- turn, updates.ToAgentResponse(), toolFault, ReadDisposition(updates), cancellationToken)
- .ConfigureAwait(false);
+ result?.Value = completion.Result
+ ?? throw new InvalidOperationException(
+ $"The turn {turn.Index} of the conversation '{_session.ConversationId}' ended without a seal.");
}
finally
{
- _session.Interruptions.EndRun(cancellation);
- turn.Activity?.Dispose();
+ EndRun(turn, slot);
}
}
+ /// Closes the turn's cut window and span, and frees the conversation for the next turn, here and in the store.
+ private void EndRun(ConversationTurn turn, TurnCutSlot slot)
+ {
+ _session.Cuts.EndRun(slot);
+ turn.Activity?.Dispose();
+ _ = _session.Busy.Exit();
+ }
+
+ ///
+ /// Ends a turn whose was disposed before its reply was ever read. The turn's
+ /// iterator never started, so nothing in ran: no model call, no seal, and
+ /// without this, no outcome tag and no turn.duration point either, though the span
+ /// opened is still live and must still end.
+ /// It is given the outcome and duration a stopped turn gets, because that is exactly what it is:
+ /// a turn cut before it produced anything, with nothing played. It keeps none of its words, so it is refused
+ /// as dropped, and the refusal leaves its log line and its audit row.
+ ///
+ private void AbandonRun(ConversationTurn turn, TurnCutSlot slot)
+ {
+ TurnRefusals.Raise(_session, turn.Index, TurnRefusals.Dropped);
+
+ AgentCoreTelemetry.EndTurn(
+ turn.Activity,
+ _session.Time.GetElapsedTime(turn.StartedAt),
+ AgentCoreTelemetry.OutcomeInterrupted,
+ turn.StageBefore,
+ failure: null);
+
+ EndRun(turn, slot);
+ }
+
/// Reads whether the caller hears the node that produced one update.
/// One update of the run.
/// when the host should speak it.
- internal bool Speaks(AgentResponseUpdate update)
+ internal bool IsOutput(AgentResponseUpdate update)
{
- return _session.Compiled.SpokenBy is not { } spoken
+ return _session.Compiled.OutputAgents is not { } spoken
|| (update.AuthorName is { } author && spoken.Contains(author))
|| update.AdditionalProperties?.Contains() is true;
}
diff --git a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs b/src/AgentCore.Application/Runtime/ConversationWorkspace.cs
index 84b642d9..868c934c 100644
--- a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs
+++ b/src/AgentCore.Application/Runtime/ConversationWorkspace.cs
@@ -4,10 +4,22 @@
namespace AgentCore.Application.Runtime
{
///
- /// The folder one conversation owns on disk: created with the conversation, and deleted when it ends.
+ /// The folder one conversation owns on disk: created with the session, and deleted when the session closes, unloads, or ends.
///
internal sealed class ConversationWorkspace
{
+ ///
+ /// The suffix appends to a workspace's path to name its marker file, a sibling of
+ /// the folder rather than a file inside it: root/conversationId.agentcore-workspace. The boot
+ /// sweep () deletes a
+ /// folder only when its marker is present, so a workspace root a host points at a folder used for
+ /// anything else — the current directory, a home directory, a shared volume — never loses a folder
+ /// AgentCore did not make. Kept outside the folder because a model can read, list, and write inside a
+ /// workspace: a marker sitting in there would show up in a file listing or a memory index, and would
+ /// make the folder non-empty for a tool that expects one, such as a bare git clone.
+ ///
+ internal const string MarkerFileName = ".agentcore-workspace";
+
private ConversationWorkspace(string path)
{
Path = path;
@@ -16,8 +28,19 @@ private ConversationWorkspace(string path)
/// Gets the folder's path.
public string Path { get; }
+ /// Gets the path of this workspace's marker file: plus .
+ internal string MarkerPath => MarkerPathFor(Path);
+
+ /// Gets the marker path a folder at would have.
+ internal static string MarkerPathFor(string workspacePath)
+ {
+ return workspacePath + MarkerFileName;
+ }
+
///
- /// Creates (or, for a resumed conversation, reuses) the folder root/conversationId.
+ /// Creates (or, for a resumed conversation, reuses) the folder root/conversationId: writes its
+ /// sibling marker the boot sweep looks for, and stamps the folder with now, so a reused folder is never
+ /// mistaken for one a crashed process left behind before its first turn even runs.
///
/// The root a host bound.
///
@@ -25,11 +48,14 @@ private ConversationWorkspace(string path)
/// checked, not the text of the id, so this also refuses a conversationId of ".", which
/// would otherwise resolve straight back to the root.
///
+ /// The clock to stamp with. Defaults to the system clock.
+ /// Where a failed marker write or stamp is logged, at Warning. May be .
///
/// is empty or white space, or resolves to a path that is not a strict
/// child of .
///
- public static ConversationWorkspace Create(string root, string conversationId)
+ public static ConversationWorkspace Create(
+ string root, string conversationId, TimeProvider? time = null, ILogger? logger = null)
{
ArgumentException.ThrowIfNullOrEmpty(root);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
@@ -60,12 +86,66 @@ public static ConversationWorkspace Create(string root, string conversationId)
}
_ = Directory.CreateDirectory(path);
- return new ConversationWorkspace(path);
+
+ ConversationWorkspace workspace = new(path);
+
+ // A marker that cannot be written — its path collides with something else, say — must not stop the
+ // conversation from getting a working folder. It is simply never a candidate the boot sweep considers.
+ string marker = workspace.MarkerPath;
+ try
+ {
+ if (!File.Exists(marker))
+ {
+ File.WriteAllText(marker, string.Empty);
+ }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ if (logger is not null)
+ {
+ SessionOwnerLog.WorkspaceMarkerWriteFailed(logger, marker, exception);
+ }
+ }
+
+ // A reused folder's mtime can be well older than the idle timeout: CreateDirectory above did not
+ // touch it, since the folder already existed. Stamping here means "held" implies "stamped within
+ // the idle timeout" from the very moment a caller has the workspace, not only from its first turn.
+ workspace.Touch(time ?? TimeProvider.System, logger);
+ return workspace;
+ }
+
+ ///
+ /// Stamps the folder's last-write time with 's now. Called at the start of every
+ /// turn, so the boot sweep on another server sharing the workspace root can tell this folder is still in
+ /// active use from one a crashed process left behind (see ).
+ /// Never throws: a failed stamp must not fail the turn it belongs to.
+ ///
+ /// The clock to stamp with. A test passes a fake clock so the stamp is deterministic.
+ /// Where a failed stamp is logged, at Warning. May be .
+ public void Touch(TimeProvider time, ILogger? logger)
+ {
+ ArgumentNullException.ThrowIfNull(time);
+
+ try
+ {
+ Directory.SetLastWriteTimeUtc(Path, time.GetUtcNow().UtcDateTime);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ // A close racing this stamp, or an idle poll racing another touch, can delete the folder in the
+ // window between the failing call and this check: that is the ordinary outcome of the race, not
+ // a fault worth logging. The runtime throws FileNotFoundException here, not
+ // DirectoryNotFoundException, so this checks what is actually on disk rather than one exception type.
+ if (logger is not null && Directory.Exists(Path))
+ {
+ SessionOwnerLog.WorkspaceStampFailed(logger, Path, exception);
+ }
+ }
}
///
- /// Deletes the folder recursively. Safe to call twice: the second call finds nothing and does
- /// not log. Never throws out of a caller ending a conversation.
+ /// Deletes the folder recursively, and its sibling marker. Safe to call twice: the second call finds
+ /// nothing and does not log. Never throws out of a caller ending a conversation.
///
/// Where a failed delete is logged, at Warning. May be .
public void Delete(ILogger? logger)
@@ -84,7 +164,20 @@ public void Delete(ILogger? logger)
// between the failing operation and this check; that is not a failure worth logging.
if (logger is not null && Directory.Exists(Path))
{
- Log.WorkspaceDeleteFailed(logger, Path, exception);
+ SessionOwnerLog.WorkspaceDeleteFailed(logger, Path, exception);
+ }
+ }
+
+ string marker = MarkerPath;
+ try
+ {
+ File.Delete(marker);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ if (logger is not null && File.Exists(marker))
+ {
+ SessionOwnerLog.WorkspaceMarkerDeleteFailed(logger, marker, exception);
}
}
}
diff --git a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs b/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs
index ac8d4b52..a8637e8e 100644
--- a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs
+++ b/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs
@@ -1,15 +1,14 @@
-using Microsoft.Agents.AI;
using System.Text.Json;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Runtime.Harness;
+using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace AgentCore.Application.Runtime
{
- // Runs one delegated agent directly — the same RunAsync every other run goes through —
- // instead of the framework's agent-as-function machinery, which builds the nested run's
- // options where no caller can file the turn. The bridge files the nested turn and the
- // tools this delegation is offered on the nested run's own options, exactly like the
- // loop does for the outer run. One nested run, one fresh session: concurrent delegations
- // never share one.
+#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0.
internal static class DelegatedAgentRun
{
/// Runs one inner agent for one delegating tool call.
@@ -17,6 +16,15 @@ internal static class DelegatedAgentRun
/// What the outer agent asked, in words.
/// The parent turn, stripped per K42 — or null outside a turn.
/// The tools this delegation is offered, or null for none.
+ ///
+ /// Every background provider the document compiled. The delegation's session is fresh and this
+ /// call is its only owner, so its background children start releasing off this call's own path
+ /// when it returns — on fault or cancel too, the same duty a conversation's own end discharges for
+ /// its session. Awaiting the release inline here, under , would
+ /// hold the outer turn (and a barge-in on it) for up to
+ /// 's timeout whenever this delegation started a child that
+ /// ignores its cancel (R4-5).
+ ///
/// Cancels the run.
/// What the inner agent answered, as the outer agent reads it.
internal static async ValueTask RunAsync(
@@ -24,39 +32,80 @@ internal static class DelegatedAgentRun
string query,
TurnInvocation? nested,
IReadOnlyList? tools,
+ IReadOnlyList backgroundProviders,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(query);
+ ArgumentNullException.ThrowIfNull(backgroundProviders);
AgentSession session = await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
- ChatOptions chat = new()
+ try
{
- AdditionalProperties = [],
- };
+ ChatOptions chat = new()
+ {
+ AdditionalProperties = [],
+ };
+
+ if (nested is not null)
+ {
+ chat.AdditionalProperties[TurnInvocation.ArgumentsKey] = nested;
+ }
+
+ if (tools is { Count: > 0 })
+ {
+ chat.Tools = [.. tools];
+ }
- if (nested is not null)
+ AgentResponse response = await inner.RunAsync(
+ [new ChatMessage(ChatRole.User, query)],
+ session,
+ new ChatClientAgentRunOptions(chat),
+ cancellationToken).ConfigureAwait(false);
+
+ return response.Text is { } text
+ ? JsonSerializer.SerializeToElement(text)
+ : null;
+ }
+ finally
{
- chat.AdditionalProperties[TurnInvocation.ArgumentsKey] = nested;
+ if (backgroundProviders.Count > 0)
+ {
+ ReleaseInBackground(
+ backgroundProviders,
+ session,
+ nested?.ConversationId ?? "(delegation outside a turn)",
+ nested?.Logger ?? NullLogger.Instance);
+ }
}
+ }
- if (tools is { Count: > 0 })
+ ///
+ /// Starts releasing the delegation's session off the caller's path, and logs the outcome. Nothing
+ /// here awaits the release, so it can never stall the outer turn or a barge-in on it.
+ ///
+ private static void ReleaseInBackground(
+ IReadOnlyList backgroundProviders, AgentSession session, string conversationId, ILogger logger)
+ {
+ _ = Task.Run(() => ReleaseLoggingFailureAsync(backgroundProviders, session, conversationId, logger));
+ }
+
+ private static async Task ReleaseLoggingFailureAsync(
+ IReadOnlyList backgroundProviders, AgentSession session, string conversationId, ILogger logger)
+ {
+ try
{
- chat.Tools = [.. tools];
+ await BackgroundSessionRelease.ReleaseAsync(
+ backgroundProviders, session, conversationId, logger, CancellationToken.None).ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Nothing awaits this release once the delegation returns, so nothing may escape it.
+ catch (Exception exception)
+#pragma warning restore CA1031
+ {
+ SessionOwnerLog.BackgroundReleaseFailed(logger, conversationId, exception);
}
-
- AgentResponse response = await inner.RunAsync(
- [new ChatMessage(ChatRole.User, query)],
- session,
- new ChatClientAgentRunOptions(chat),
- cancellationToken).ConfigureAwait(false);
-
- // Marshaled like the framework's own agent-as-function result: the outer loop reads
- // words either way, but callers pin the JsonElement shape.
- return response.Text is { } text
- ? JsonSerializer.SerializeToElement(text)
- : null;
}
}
+#pragma warning restore MAAI001
}
diff --git a/src/AgentCore.Application/Runtime/FallbackAgent.cs b/src/AgentCore.Application/Runtime/FallbackAgent.cs
index 76ad94d7..6973e970 100644
--- a/src/AgentCore.Application/Runtime/FallbackAgent.cs
+++ b/src/AgentCore.Application/Runtime/FallbackAgent.cs
@@ -7,59 +7,26 @@ namespace AgentCore.Application.Runtime
///
/// Answers a run that threw, or one that spoke no words at all, with the spoken fallback.
///
- ///
- ///
- /// R1: a tool that fails four times in a row throws out of the run, and section 8.7 says that must
- /// never kill the conversation. R2: at 40 tool rounds the framework sends request 41 with no tools and
- /// returns quietly, with no exception — on a voice conversation that silence is the failure, so an empty
- /// reply is read rather than trusting the absence of an error. Both end here, and both leave the
- /// turn an ordinary successful run.
- ///
- ///
- /// A cancel is not a fault. is never swallowed: a
- /// barge-in ends the turn on the caller's terms and the turn loop records what was heard.
- ///
- ///
- /// It wraps an AGENT and not a chat client. One turn is one run of one agent on every row of
- /// the compile table. A chat-pipeline layer would answer each graph node separately, and a node that
- /// went quiet would then speak the fallback INTO the graph — which is exactly the silent graph
- /// failure section 8.2 refuses to ship, hidden rather than raised.
- ///
- ///
- /// A run that threw carries no messages out, because the fault ended it. An empty run keeps the
- /// messages it did produce, so a tool pair that finished stays visible to the next turn.
- ///
- ///
- /// On a graph row the emptiness that matters is the OUTPUT node's, not the run's. Measured on
- /// Microsoft.Agents.AI 1.17.0: a sequential graph whose last node answers with whitespace returns one
- /// message, and it is the node BEFORE it. Reading the run as "it produced text" would put the graph's
- /// own deliberation in the caller's ear. The constructor's spokenBy set names the agents whose
- /// reply the caller hears, so a quiet output node reads as the silence it is.
- ///
- ///
- /// It holds no per-call state: one instance is compiled once and shared by every conversation under T44 and
- /// R7.
- ///
- ///
internal sealed class FallbackAgent : DelegatingAIAgent
{
private readonly string _fallbackReply;
- private readonly IReadOnlySet? _spokenBy;
+
+ private readonly IReadOnlySet? _outputAgents;
/// Puts the fallback in front of one turn agent.
/// The agent a turn runs.
/// What a failed turn speaks.
- ///
+ ///
/// The agents.items ids whose reply the caller hears, or when the
/// last thing the run produced is that reply. It is null for rows 1 and 2, which run one agent,
/// and for the graph patterns where any participant may answer last.
///
- public FallbackAgent(AIAgent inner, string fallbackReply, IReadOnlySet? spokenBy = null)
+ public FallbackAgent(AIAgent inner, string fallbackReply, IReadOnlySet? outputAgents = null)
: base(inner)
{
ArgumentNullException.ThrowIfNull(fallbackReply);
_fallbackReply = fallbackReply;
- _spokenBy = spokenBy;
+ _outputAgents = outputAgents;
}
///
@@ -76,12 +43,12 @@ protected override async Task RunCoreAsync(
.ConfigureAwait(false);
}
#pragma warning disable CA1031 // Section 8.7, row six: the run throws, the turn ends, and the conversation lives.
- catch (Exception exception) when (exception is not OperationCanceledException)
+ catch (Exception exception) when (IsFault(exception, cancellationToken))
#pragma warning restore CA1031
{
AgentResponse spoken = new(new ChatMessage(ChatRole.Assistant, _fallbackReply));
spoken.AdditionalProperties ??= [];
- spoken.AdditionalProperties.Add(Disposition(FallbackCause.Faulted, exception.Message));
+ spoken.AdditionalProperties.Add(Disposition(FallbackCause.Faulted, exception));
return spoken;
}
@@ -112,7 +79,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
bool spokeText = false;
FallbackCause cause = FallbackCause.None;
- string? reason = null;
+ Exception? reason = null;
try
{
@@ -127,11 +94,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
}
#pragma warning disable CA1031 // Section 8.7, row six. See the buffered path above.
- catch (Exception exception) when (exception is not OperationCanceledException)
+ catch (Exception exception) when (IsFault(exception, cancellationToken))
#pragma warning restore CA1031
{
cause = FallbackCause.Faulted;
- reason = exception.Message;
+ reason = exception;
}
if (cause is not FallbackCause.None || current is null)
@@ -139,7 +106,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
break;
}
- spokeText = spokeText || HasApprovalRequest(current) || (Speaks(current.AuthorName) && !string.IsNullOrWhiteSpace(current.Text));
+ spokeText = spokeText || HasApprovalRequest(current) || (IsOutput(current.AuthorName) && !string.IsNullOrWhiteSpace(current.Text));
yield return current;
}
}
@@ -162,14 +129,21 @@ protected override async IAsyncEnumerable RunCoreStreamingA
}
}
+ ///
+ /// Distinguishes a caller cancel from every other : only the
+ /// former is not a fault.
+ ///
+ /// What the run or the stream threw.
+ /// The token this run was cancelled by, if the caller cancelled it.
+ /// when the turn should take the fallback rather than propagate.
+ private static bool IsFault(Exception exception, CancellationToken cancellationToken)
+ {
+ return exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested;
+ }
+
/// Reads whether the run asks the caller to approve a tool call.
/// What the inner agent answered.
/// Whether any message carries an approval request.
- ///
- /// A request-only reply carries no text, so without this the run reads as silence and takes
- /// the fallback. An approval the caller never hears is a tool that never runs, so a request
- /// counts as spoken whoever authored it.
- ///
private static bool HasApprovalRequest(IEnumerable messages)
{
return messages.Any(message => HasApprovalRequest(message.Contents));
@@ -193,21 +167,21 @@ private static bool HasApprovalRequest(IEnumerable contents)
/// The spoken text, or an empty string when nothing the caller hears was produced.
private string SpokenText(AgentResponse response)
{
- return SpokenReply.From(response.Messages, _spokenBy);
+ return ReplyText.From(response.Messages, _outputAgents);
}
/// Reads whether the caller hears what this author said.
/// The node that produced the message or the update, if any.
/// when the text counts as the reply.
- private bool Speaks(string? authorName)
+ private bool IsOutput(string? authorName)
{
- return _spokenBy is null || (authorName is not null && _spokenBy.Contains(authorName));
+ return _outputAgents is null || (authorName is not null && _outputAgents.Contains(authorName));
}
///
/// What this layer alone knows. folds its own verdict in above.
///
- private static TurnDisposition Disposition(FallbackCause cause, string? reason)
+ private static TurnDisposition Disposition(FallbackCause cause, Exception? reason)
{
return new(Moderation: null, FlaggedCategories: null, cause, reason, ModerationReason: null);
}
diff --git a/src/AgentCore.Application/Runtime/GraphFaultAgent.cs b/src/AgentCore.Application/Runtime/GraphFaultAgent.cs
new file mode 100644
index 00000000..9ba97fc5
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/GraphFaultAgent.cs
@@ -0,0 +1,88 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.ExceptionServices;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Rethrows the fault a graph node raised, and the caller's cancel. AsAIAgent() reports a fault as an
+ /// update whose is the
+ /// or , and never throws; without this the
+ /// turn reads an empty reply.
+ ///
+ internal sealed class GraphFaultAgent(AIAgent inner, bool drain) : DelegatingAIAgent(inner)
+ {
+ ///
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ // The merged response drops each update's RawRepresentation, so the non-streaming path reads the stream.
+ return RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Exception? fault = null;
+
+ await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ if (fault is not null)
+ {
+ continue;
+ }
+
+ if (FaultOf(update) is { } thisFault)
+ {
+ fault = thisFault;
+
+ if (!drain)
+ {
+ break;
+ }
+
+ continue;
+ }
+
+ yield return update;
+ }
+
+ if (fault is not null)
+ {
+ ExceptionDispatchInfo.Capture(fault).Throw();
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ private static Exception? FaultOf(AgentResponseUpdate update)
+ {
+ Exception? fault = update.RawRepresentation switch
+ {
+ ExecutorFailedEvent failed => failed.Data
+ ?? new InvalidOperationException($"The graph node '{failed.ExecutorId}' failed, and MAF reported no exception."),
+ WorkflowErrorEvent error => error.Exception
+ ?? new InvalidOperationException("The graph reported a workflow error with no exception."),
+ _ => null,
+ };
+
+ while (fault is TargetInvocationException { InnerException: { } inner })
+ {
+ fault = inner;
+ }
+
+ return fault;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs
index 38ce5802..76e48fed 100644
--- a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs
+++ b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using AgentCore.Application.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -6,7 +7,9 @@ namespace AgentCore.Application.Runtime.Harness
{
///
/// Releases the harness background providers' sessions when a conversation ends, so children still
- /// running cannot outlive it. A task that spans turns is untouched until the end.
+ /// running cannot outlive it. A task that spans turns is untouched until the end, unless another session's
+ /// turn makes this one rebuild its session from the store: the old session is released then, without the turn
+ /// waiting for it.
///
internal static class BackgroundSessionRelease
{
@@ -17,12 +20,18 @@ internal static class BackgroundSessionRelease
///
private static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(5);
+ ///
+ /// How early MAF's timeout can end by 's clock: timers run on a coarse tick of a few
+ /// milliseconds.
+ ///
+ private static readonly TimeSpan TimerSlack = TimeSpan.FromMilliseconds(100);
+
///
/// Cancels each provider's running tasks on and releases the
/// session. Best effort: one provider's fault is logged and the rest still release.
///
/// Every background provider the document compiled.
- /// The session of the conversation that ended.
+ /// The session of the conversation that ended, or the one a catch-up replaced.
/// The id of the conversation, for the log.
/// The logger of the session.
/// Cancels the release.
@@ -43,12 +52,20 @@ public static async ValueTask ReleaseAsync(
{
try
{
+ long started = Stopwatch.GetTimestamp();
+
await provider.ReleaseSessionAsync(session, cancelRunning: true, ReleaseTimeout, cancellationToken)
.ConfigureAwait(false);
+
+ // MAF gives up on a child that ignores its cancel without saying so; the full wait is the only sign.
+ if (Stopwatch.GetElapsedTime(started) >= ReleaseTimeout - TimerSlack)
+ {
+ SessionOwnerLog.BackgroundReleaseTimedOut(logger, conversationId, ReleaseTimeout.TotalSeconds);
+ }
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
- Log.BackgroundReleaseFailed(logger, conversationId, exception);
+ SessionOwnerLog.BackgroundReleaseFailed(logger, conversationId, exception);
}
}
}
diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs
index a54b1445..108ab96d 100644
--- a/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs
+++ b/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs
@@ -3,20 +3,9 @@
namespace AgentCore.Application.Runtime.Harness
{
- // The store behind an agent's files: block, bound per invocation: the framework's
- // own file-access tools do the work, rooted at the running conversation's workspace folder. A
- // shared store cannot serve every conversation — the overrides take no session — so this builds
- // the framework provider per conversation around the same options, exactly like the prefetch
- // search provider. State keys and post-invocation storage forward to the shared template:
- // accumulation lives in the session store, not on any instance.
-
#pragma warning disable MAAI001 // File-store types are evaluation-only in Microsoft.Agents.AI 1.21.0.
-
internal sealed class ConversationFilesProvider : AIContextProvider, IDisposable
{
- private const string NoTurnMessage =
- "A files: tool runs only while a turn runs through a ConversationSession with a workspace root bound.";
-
private readonly FileAccessProvider _template;
private readonly FileAccessProviderOptions _options;
@@ -47,12 +36,11 @@ public ConversationFilesProvider(string root, FileAccessProviderOptions options)
protected override async ValueTask InvokingCoreAsync(
InvokingContext context, CancellationToken cancellationToken = default)
{
- string workspace = TurnRegistry.For(context.Session)?.Workspace
- ?? throw new InvalidOperationException(NoTurnMessage);
+ if (TurnRegistry.For(context.Session)?.Workspace is not { } workspace)
+ {
+ return new AIContext();
+ }
- // Not using-declared on purpose: the tools this returns run after this method does,
- // and disposing the provider would dispose the store out from under them. The store
- // holds a path, not handles, so ordinary collection reclaims it.
FileAccessProvider inner = new(new FileSystemAgentFileStore(workspace), _options);
return await inner.InvokingAsync(context, cancellationToken).ConfigureAwait(false);
diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs
index e24a1a31..a9752ec1 100644
--- a/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs
+++ b/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs
@@ -5,16 +5,10 @@
namespace AgentCore.Application.Runtime.Harness
{
///
- /// The instructions behind an agent's shell: block: the shell family, version, working
- /// directory and CLI versions, probed once per call through that conversation's executor and rendered
- /// with MAF's own formatter. No StateKeys — the snapshot lives on
- /// and dies with the conversation, so resume re-probes a fresh environment.
+ /// The instructions behind an agent's shell: block.
///
internal sealed class ConversationShellEnvironmentProvider : AIContextProvider
{
- private const string NoTurnMessage =
- "A shell: tool runs only while a turn runs through a ConversationSession with a workspace root bound.";
-
private readonly ConversationShellOptions _options;
public ConversationShellEnvironmentProvider(ConversationShellOptions options)
@@ -26,8 +20,10 @@ public ConversationShellEnvironmentProvider(ConversationShellOptions options)
protected override async ValueTask ProvideAIContextAsync(
InvokingContext context, CancellationToken cancellationToken = default)
{
- ConversationShells shells = TurnRegistry.For(context.Session)?.Shells
- ?? throw new InvalidOperationException(NoTurnMessage);
+ if (TurnRegistry.For(context.Session)?.Shells is not { } shells)
+ {
+ return new AIContext();
+ }
ShellEnvironmentSnapshot snapshot = await shells.GetEnvironmentAsync(_options, cancellationToken).ConfigureAwait(false);
diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs
index 3dc41bd3..24308de1 100644
--- a/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs
+++ b/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs
@@ -4,16 +4,10 @@
namespace AgentCore.Application.Runtime.Harness
{
///
- /// The context provider behind an agent's shell: block: on every invocation it hands the
- /// model this conversation's shell executor as MAF's own tool. No instructions of our own — MAF's tool
- /// description is what the model reads — and no StateKeys, because the shell keeps no
- /// session state.
+ /// The context provider behind an agent's shell: block.
///
internal sealed class ConversationShellProvider : AIContextProvider
{
- private const string NoTurnMessage =
- "A shell: tool runs only while a turn runs through a ConversationSession with a workspace root bound.";
-
private readonly ConversationShellOptions _options;
public ConversationShellProvider(ConversationShellOptions options)
@@ -22,16 +16,16 @@ public ConversationShellProvider(ConversationShellOptions options)
_options = options;
}
+ // A delegated agent's run files no turn on its own session, so the shell has no workspace to
+ // run in. The tool is simply absent rather than a thrown fault (design §6.3#6).
protected override ValueTask ProvideAIContextAsync(
InvokingContext context, CancellationToken cancellationToken = default)
{
- ConversationShells shells = TurnRegistry.For(context.Session)?.Shells
- ?? throw new InvalidOperationException(NoTurnMessage);
+ ConversationShells? shells = TurnRegistry.For(context.Session)?.Shells;
- return new ValueTask(new AIContext
- {
- Tools = [shells.Get(_options).AsAIFunction(requireApproval: false)],
- });
+ return new ValueTask(shells is null
+ ? new AIContext()
+ : new AIContext { Tools = [shells.Get(_options).AsAIFunction(requireApproval: false)] });
}
}
}
diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs
index 547d6865..5e8e9a24 100644
--- a/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs
+++ b/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs
@@ -228,7 +228,7 @@ private ShellExecutor Create(ConversationShellOptions options)
///
/// Disposes every executor this conversation started. Idempotent, and never throws out: a failed
- /// dispose is logged at Warning through instead. A second
+ /// dispose is logged at Warning through instead. A second
/// concurrent caller returns immediately rather than wait for the first dispose to finish — the
/// executors are only ever taken out and disposed once.
///
@@ -258,7 +258,7 @@ public async ValueTask DisposeAsync()
{
if (_logger is not null)
{
- Log.ShellDisposeFailed(_logger, _workspace, exception);
+ SessionOwnerLog.ShellDisposeFailed(_logger, _workspace, exception);
}
}
}
diff --git a/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs b/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs
index 926d560f..8c55f6a5 100644
--- a/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs
+++ b/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs
@@ -12,6 +12,8 @@ namespace AgentCore.Application.Runtime.Harness
///
internal static class HarnessSessionState
{
+ private const string StateBagKey = "stateBag";
+
private static readonly IReadOnlyDictionary Empty =
ReadOnlyDictionary.Empty;
@@ -50,6 +52,13 @@ public static IReadOnlyDictionary Capture(AgentSession? ses
/// { key: element, ... } }, with no other member.
///
public static JsonElement Wrap(IReadOnlyDictionary providers)
+ {
+ JsonObject envelope = new() { [StateBagKey] = BagOf(providers) };
+
+ return JsonSerializer.SerializeToElement(envelope);
+ }
+
+ private static JsonObject BagOf(IReadOnlyDictionary providers)
{
JsonObject stateBag = [];
@@ -58,9 +67,7 @@ public static JsonElement Wrap(IReadOnlyDictionary provider
stateBag[key] = JsonSerializer.SerializeToNode(element);
}
- JsonObject envelope = new() { ["stateBag"] = stateBag };
-
- return JsonSerializer.SerializeToElement(envelope);
+ return stateBag;
}
}
}
diff --git a/src/AgentCore.Application/Runtime/ITurnCompleter.cs b/src/AgentCore.Application/Runtime/ITurnCompleter.cs
new file mode 100644
index 00000000..328b1f65
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ITurnCompleter.cs
@@ -0,0 +1,44 @@
+using AgentCore.Application.Transcript;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// The conversation's side of one turn's seal, which calls in a fixed
+ /// order: , then the provider's commit under the turn lock, then
+ /// , then under the turn lock and .
+ /// A turn the store refused stops after .
+ ///
+ internal interface ITurnCompleter
+ {
+ /// Reports that the turn's edit withdrew earlier turns, before the run starts.
+ /// The turns the edit took.
+ void Superseded(WithdrawnTurns withdrawn);
+
+ ///
+ /// Decides the turn after its run: the tool-result writers, the extractor, the clock, the counters and the
+ /// stage advance.
+ ///
+ /// The turn as the layer saw it end.
+ /// The fault the run threw, or .
+ /// completed with the state and the line the record holds.
+ ValueTask CompleteAsync(TurnCommit sealing, Exception? fault);
+
+ ///
+ /// Waits for the store's answer to the commit, and closes the turn when the store refused it because another
+ /// session of the conversation saved the same turn first.
+ ///
+ /// Completes with when the store refused the commit.
+ /// when the turn was refused, and publishes nothing.
+ ValueTask RefusedAsync(Task refused);
+
+ /// Raises the turn's events once its words are written. Runs under the turn lock.
+ /// What the commit wrote the user's message and the last message under, or .
+ /// The words the turn's rows say (), which the turn's hashes prove.
+ /// A cut that arrived after the seal and before the commit, or .
+ /// A recut of the turn's own cut that arrived after the seal and before the commit, or .
+ void Committed((string UserMessageId, string? ReplyMessageId)? ids, string spoken, TurnCut? late, TurnCut? recut);
+
+ /// Closes the turn after the lock: its telemetry, and the conversation when it is over.
+ ValueTask FinishAsync();
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/ModerationAgent.cs b/src/AgentCore.Application/Runtime/ModerationAgent.cs
index d53f8c75..e222cce3 100644
--- a/src/AgentCore.Application/Runtime/ModerationAgent.cs
+++ b/src/AgentCore.Application/Runtime/ModerationAgent.cs
@@ -14,7 +14,9 @@ internal sealed class ModerationAgent : DelegatingAIAgent
internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2);
private readonly PromptModerator _moderator;
+
private readonly string _refusalReply;
+
private readonly TimeSpan _timeout;
/// Puts moderation in front of one turn agent.
@@ -85,10 +87,6 @@ private async IAsyncEnumerable RunCoreStreamingCoreAsync(
yield break;
}
- // A leading update with EMPTY contents. Empty contents contribute no text, so the caller's
- // audio is untouched and the turn seam drops the update before the host ever sees it.
- // Update-level properties do not survive streaming coalescing, which is why the marker rides
- // an update of its own rather than the assistant message the updates fold into.
AgentResponseUpdate marker = new() { Role = ChatRole.Assistant };
Attach(marker.AdditionalProperties ??= [], verdict);
yield return marker;
@@ -170,7 +168,7 @@ private static void Attach(AdditionalPropertiesDictionary properties, TurnDispos
{
if (properties.TryGetValue(out TurnDisposition? existing) && existing is not null)
{
- verdict = verdict with { Fallback = existing.Fallback, FallbackReason = existing.FallbackReason };
+ verdict = verdict with { Fallback = existing.Fallback, FallbackFault = existing.FallbackFault };
_ = properties.Remove();
}
diff --git a/src/AgentCore.Application/Runtime/ReplyOutcome.cs b/src/AgentCore.Application/Runtime/ReplyOutcome.cs
index de95e5d2..824086c1 100644
--- a/src/AgentCore.Application/Runtime/ReplyOutcome.cs
+++ b/src/AgentCore.Application/Runtime/ReplyOutcome.cs
@@ -4,16 +4,30 @@ namespace AgentCore.Application.Runtime
{
/// What the turn said, what the caller heard, and why they differ if they do.
/// The text the record holds: what the caller heard.
- /// The text the model produced, before any barge-in cut it.
+ ///
+ /// The text the model produced across every step, before any cut. Only a cut turn's turn.completed proves it:
+ /// every other turn proves the words its rows say.
+ ///
/// The section 8.7 reason, or .
- /// The row-six fault, from the run or from the fallback layer.
+ /// The message of the fault a tool let out (row six), or when no tool did.
/// The played duration of a cut reply, or .
/// The tool calls still waiting on a human answer.
+ ///
+ /// The fault that ended the run, live and not flattened to a message: the Error log needs the object, for its
+ /// stack trace.
+ ///
+ ///
+ /// Whether a tool call let out (): a tool that spent its retry
+ /// budget. Otherwise the run faulted outside every tool, say because the model endpoint did not answer, whether
+ /// caught it or it reached the turn from above that layer.
+ ///
internal sealed record ReplyOutcome(
string Reply,
- string SpokenReply,
+ string GeneratedText,
string? Failure,
string? ToolFault,
TimeSpan? InterruptedAfter,
- List Approvals);
+ List Approvals,
+ Exception? Fault = null,
+ bool IsToolFault = false);
}
diff --git a/src/AgentCore.Application/Runtime/ReplyText.cs b/src/AgentCore.Application/Runtime/ReplyText.cs
new file mode 100644
index 00000000..42c2539a
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ReplyText.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Picks the one message of a run that the caller actually hears.
+ ///
+ internal static class ReplyText
+ {
+ /// Reads the words this run puts in the caller's ear.
+ /// The messages of the run, oldest first.
+ ///
+ /// The agents.items ids whose reply the caller hears, or when every
+ /// message counts. CompiledAgent.OutputAgents names them.
+ ///
+ /// The spoken text, or an empty string when the run produced none.
+ internal static string From(IList messages, IReadOnlySet? outputAgents = null)
+ {
+ for (int index = messages.Count - 1; index >= 0; index--)
+ {
+ ChatMessage message = messages[index];
+
+ if (message.Contents.Count == 0)
+ {
+ continue;
+ }
+
+ if (outputAgents is not null && (message.AuthorName is not { } author || !outputAgents.Contains(author)))
+ {
+ continue;
+ }
+
+ return message.Text;
+ }
+
+ return string.Empty;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/SpokenReply.cs b/src/AgentCore.Application/Runtime/SpokenReply.cs
deleted file mode 100644
index 9b032997..00000000
--- a/src/AgentCore.Application/Runtime/SpokenReply.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using Microsoft.Extensions.AI;
-
-namespace AgentCore.Application.Runtime
-{
- ///
- /// Picks the one message of a run that the caller actually hears.
- ///
- ///
- ///
- /// AgentResponse.Text concatenates every message of the response. On a graph row that is every
- /// node's reply, so the caller would hear the graph deliberating. The last message is the spoken one
- /// — but only after two artefacts are stepped over, and both were measured on Microsoft.Agents.AI
- /// 1.17.0.
- ///
- ///
- /// A folded stream is mostly lifecycle. Collecting a two-node graph's updates and calling
- /// ToAgentResponse() gave 25 messages for two replies: 23 of them carried no content at all,
- /// and ten of those sat after the last spoken word. Reading the last message flatly would therefore
- /// report every streaming graph turn as an empty reply.
- ///
- ///
- /// A quiet node vanishes when buffered. A sequential graph whose last node answers with
- /// whitespace returns one message, and it is the node BEFORE it. Naming the agents the caller hears
- /// is what keeps that deliberation out of the caller's ear.
- ///
- ///
- internal static class SpokenReply
- {
- /// Reads the words this run puts in the caller's ear.
- /// The messages of the run, oldest first.
- ///
- /// The agents.items ids whose reply the caller hears, or when every
- /// message counts. CompiledAgent.SpokenBy names them.
- ///
- /// The spoken text, or an empty string when the run produced none.
- internal static string From(IList messages, IReadOnlySet? spokenBy = null)
- {
- for (int index = messages.Count - 1; index >= 0; index--)
- {
- ChatMessage message = messages[index];
-
- // A message with no content at all is a workflow lifecycle event, not speech. A message
- // that carries content and no words is a reply that said nothing, which IS the failure.
- if (message.Contents.Count == 0)
- {
- continue;
- }
-
- if (spokenBy is not null && (message.AuthorName is not { } author || !spokenBy.Contains(author)))
- {
- continue;
- }
-
- return message.Text;
- }
-
- return string.Empty;
- }
- }
-}
diff --git a/src/AgentCore.Application/Runtime/ToolFaultMark.cs b/src/AgentCore.Application/Runtime/ToolFaultMark.cs
new file mode 100644
index 00000000..d7202d06
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/ToolFaultMark.cs
@@ -0,0 +1,38 @@
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Marks a fault a tool threw, so the turn can tell a tool that spent its retry budget from a run that faulted
+ /// outside every tool. catches both, and a model endpoint that did not answer throws
+ /// the same exception types a tool's endpoint does.
+ ///
+ internal static class ToolFaultMark
+ {
+ private const string Key = "AgentCore.ToolCallId";
+
+ /// Marks a fault one tool call let out.
+ /// What the tool threw.
+ /// The id the model gave the call.
+ internal static void Put(Exception fault, string toolCallId)
+ {
+ ArgumentNullException.ThrowIfNull(fault);
+
+ fault.Data[Key] = toolCallId;
+ }
+
+ /// Reads whether a tool threw this fault, or a fault it wraps.
+ /// The fault that ended the run.
+ /// when a tool call let it out.
+ internal static bool IsOn(Exception fault)
+ {
+ ArgumentNullException.ThrowIfNull(fault);
+
+ return fault switch
+ {
+ _ when fault.Data.Contains(Key) => true,
+ AggregateException aggregate => aggregate.InnerExceptions.Any(IsOn),
+ { InnerException: { } inner } => IsOn(inner),
+ _ => false,
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/TranscriptEntry.cs b/src/AgentCore.Application/Runtime/TranscriptEntry.cs
deleted file mode 100644
index e13e6b37..00000000
--- a/src/AgentCore.Application/Runtime/TranscriptEntry.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Microsoft.Extensions.AI;
-
-namespace AgentCore.Application.Runtime
-{
- /// What this turn adds to the transcript.
- /// The assistant message the caller heard, or when nothing was.
- /// Every message store 0 appends after the caller's own; empty for rows 3 and 4.
- internal sealed record TranscriptEntry(ChatMessage? Heard, List Written);
-}
diff --git a/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs
new file mode 100644
index 00000000..98467102
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime.Turn
+{
+ ///
+ /// The ids a turn's commit wrote, on the last update of the turn. A streaming caller never sees the ids any
+ /// other way, because the framework's hook runs after the last update (design probes P08b, G3).
+ ///
+ /// What the user's message was written under.
+ /// What the last message was written under, or when the user's is the only one.
+ internal sealed class TurnCommittedContent(string userMessageId, string? replyMessageId) : AIContent
+ {
+ /// Gets what the user's message was written under.
+ public string UserMessageId { get; } = userMessageId;
+
+ /// Gets what the last message was written under, or when the user's is the only one.
+ public string? ReplyMessageId { get; } = replyMessageId;
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs
index 0ba9b38f..f6764d9e 100644
--- a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs
+++ b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs
@@ -16,7 +16,7 @@ internal static class TurnMessages
///
/// What names the caller in that message.
///
- internal const string CallerLinePrefix = "Caller: ";
+ internal const string UserLinePrefix = "User: ";
///
/// What names this agent in that message.
@@ -40,7 +40,7 @@ internal static class TurnMessages
}
_ = rendered
- .Append(message.Role == ChatRole.User ? CallerLinePrefix : AgentLinePrefix)
+ .Append(message.Role == ChatRole.User ? UserLinePrefix : AgentLinePrefix)
.Append(text)
.Append('\n');
}
@@ -58,10 +58,10 @@ internal static bool CarriesContent(ChatResponseUpdate update)
return update.Contents.Any(content => content is not TextContent text || text.Text.Length > 0);
}
- /// Keeps the tool calls whose results arrived, and drops every unpaired call and every word.
+ /// Keeps the words and the tool calls whose results arrived, and drops every unpaired call.
/// Every message the round produced.
- /// The tool content that carries a complete call-and-result pair, in its original order.
- internal static List FinishedToolMessages(IList messages)
+ /// The words and the complete call-and-result pairs, in their original order.
+ internal static List FinishedMessages(IList messages)
{
HashSet answered = [];
foreach (ChatMessage message in messages)
@@ -82,16 +82,16 @@ internal static List FinishedToolMessages(IList messag
[
.. message.Contents.Where(content => content switch
{
- TextContent => false,
FunctionCallContent call => answered.Contains(call.CallId),
_ => true,
}),
];
- if (!tools.Any(content => content is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent))
+ if (!tools.Any(content => content is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent
+ || content is TextContent { Text.Length: > 0 }))
{
- // Plain prose, or a message whose every call is still in flight. Neither belongs in
- // the next turn.
+ // A message whose every call is still in flight, with no words beside them. It does not
+ // belong in the next turn.
continue;
}
@@ -110,5 +110,20 @@ internal static List FinishedToolMessages(IList messag
return kept;
}
+
+ ///
+ /// The whole reply a turn's run produced, every step's text in order.
+ ///
+ /// Every message the round produced.
+ ///
+ /// Every step's text, concatenated with no separator between steps — the same rule
+ /// reconstructs a cut reply's shown text against, so a
+ /// multi-step reply's hash and its transcript rows never disagree over where one step ends and
+ /// the next begins.
+ ///
+ internal static string AllText(IList messages)
+ {
+ return string.Concat(FinishedMessages(messages).Select(message => message.Text));
+ }
}
}
diff --git a/src/AgentCore.Application/Runtime/TurnCut.cs b/src/AgentCore.Application/Runtime/TurnCut.cs
new file mode 100644
index 00000000..5afe2fdd
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/TurnCut.cs
@@ -0,0 +1,9 @@
+namespace AgentCore.Application.Runtime
+{
+ /// What the user saw or heard of a reply before it was cut.
+ ///
+ /// The text that reached the user, or for everything the engine yielded.
+ ///
+ /// How much of the reply played, when a voice layer knows it.
+ public sealed record TurnCut(string? ShownText, TimeSpan? Played);
+}
diff --git a/src/AgentCore.Application/Runtime/TurnCutSlot.cs b/src/AgentCore.Application/Runtime/TurnCutSlot.cs
new file mode 100644
index 00000000..dad1cf6a
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/TurnCutSlot.cs
@@ -0,0 +1,146 @@
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Where a cut of one running turn waits for the seal, and the turn lock that linearizes the cut against
+ /// the turn's commit (design section 3, "Command").
+ ///
+ /// The conversation's turn lock.
+ /// The host's token, which the run's own token is linked to.
+ internal sealed class TurnCutSlot(Lock turnLock, CancellationToken cancellationToken) : IDisposable
+ {
+ private readonly CancellationTokenSource _run = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ private TurnCut? _cut;
+
+ private TurnCut? _late;
+
+ private TurnCut? _lateRecut;
+
+ private bool _sealed;
+
+ private bool _committed;
+
+ /// Gets the turn lock. The commit of the turn runs under it.
+ public Lock Gate { get; } = turnLock;
+
+ /// Gets the token the run reads. A cut before the seal cancels it.
+ public CancellationToken Token => _run.Token;
+
+ /// Gets whether a cut reached the turn before its seal.
+ public bool IsCut
+ {
+ get
+ {
+ lock (Gate)
+ {
+ return _cut is not null;
+ }
+ }
+ }
+
+ /// Gets whether the turn's words are committed, so a cut from now on amends them.
+ public bool IsCommitted
+ {
+ get
+ {
+ lock (Gate)
+ {
+ return _committed;
+ }
+ }
+ }
+
+ /// Records a cut of this turn.
+ /// What the user saw or heard.
+ /// when the turn already committed, so the cut belongs to the committed turn.
+ public bool TryCut(TurnCut cut)
+ {
+ ArgumentNullException.ThrowIfNull(cut);
+
+ lock (Gate)
+ {
+ if (_committed)
+ {
+ return false;
+ }
+
+ if (_sealed)
+ {
+ _late = cut;
+ return true;
+ }
+
+ _cut = cut;
+ _run.Cancel();
+ return true;
+ }
+ }
+
+ /// Replaces the cut this turn already took.
+ /// A later account of what the user saw or heard.
+ ///
+ /// when the replacement was recorded; when the turn took no cut
+ /// before its seal, or already committed, so the recut belongs to the committed turn.
+ ///
+ public bool TryRecut(TurnCut cut)
+ {
+ ArgumentNullException.ThrowIfNull(cut);
+
+ lock (Gate)
+ {
+ if (_committed || _cut is null)
+ {
+ return false;
+ }
+
+ if (_sealed)
+ {
+ _lateRecut = cut;
+ }
+ else
+ {
+ _cut = cut;
+ }
+
+ return true;
+ }
+ }
+
+ /// Seals the turn, once: a cut from now on waits for the commit.
+ /// The cut that stopped the run, or when none did.
+ /// when the turn was already sealed.
+ public bool TrySeal(out TurnCut? cut)
+ {
+ lock (Gate)
+ {
+ cut = _cut;
+ if (_sealed)
+ {
+ return false;
+ }
+
+ _sealed = true;
+ return true;
+ }
+ }
+
+ /// Marks the turn committed. The caller holds across the commit.
+ /// The recut that arrived between the seal and the commit, or .
+ /// The cut that arrived between the seal and the commit, or .
+ public TurnCut? Commit(out TurnCut? recut)
+ {
+ lock (Gate)
+ {
+ _committed = true;
+ recut = _lateRecut;
+ return _late;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ _run.Dispose();
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/TurnDisposition.cs b/src/AgentCore.Application/Runtime/TurnDisposition.cs
index 44537a19..ba7ab3d1 100644
--- a/src/AgentCore.Application/Runtime/TurnDisposition.cs
+++ b/src/AgentCore.Application/Runtime/TurnDisposition.cs
@@ -37,29 +37,20 @@ internal enum FallbackCause
/// The moderation endpoint's order, unmodified, or when nothing was flagged.
///
/// Why the fallback layer spoke, or .
- ///
- /// The message of the fault the fallback layer caught, or . The turn's
- /// tool.failed row carries this text, so it is the exception's message and not its type name.
+ ///
+ /// The exception the fallback layer caught, or . Kept live and not flattened to a
+ /// message here: the turn's tool.failed row needs the message, and the span and the Error log
+ /// each need a different cut of it (type only, and the full object), so the decision of which text goes
+ /// where stays with the turn loop, not with this layer.
///
///
/// Why the endpoint did not answer, in the words the turn loop logs, or . It
/// is set only when is .
///
- ///
- ///
- /// Carried on when buffered, and on a leading
- /// when streaming — update-level properties do not survive
- /// streaming coalescing, so the streaming reader must take it off the update, not the folded run.
- ///
- ///
- /// It is attached and read with , which keys on the full
- /// type name, so nothing else in the pipeline can collide with it.
- ///
- ///
internal sealed record TurnDisposition(
ModerationOutcome? Moderation,
string? FlaggedCategories,
FallbackCause Fallback,
- string? FallbackReason,
+ Exception? FallbackFault,
string? ModerationReason);
}
diff --git a/src/AgentCore.Application/Runtime/TurnInvocation.cs b/src/AgentCore.Application/Runtime/TurnInvocation.cs
index 0c33e1d6..7073a9a9 100644
--- a/src/AgentCore.Application/Runtime/TurnInvocation.cs
+++ b/src/AgentCore.Application/Runtime/TurnInvocation.cs
@@ -98,6 +98,30 @@ internal IReadOnlyList Attachments()
///
public StateDocument? State { get; init; }
+ ///
+ /// Gets the session the conversation's words live on, or outside a conversation turn.
+ /// On a graph row it is not the session the run gets.
+ ///
+ public AgentSession? HistorySession { get; init; }
+
+ /// Gets the message the user sent this turn, or outside a conversation turn.
+ public ChatMessage? User { get; init; }
+
+ /// Gets where the turn hangs in the conversation the user can see, or when the caller did not say.
+ public ConversationTurnOrigin? Origin { get; init; }
+
+ ///
+ /// Gets whether the conversation so far rides the request as one system message, as on a graph row that
+ /// runs every turn on a fresh session.
+ ///
+ public bool RendersHistory { get; init; }
+
+ /// Gets the conversation's side of the turn's seal, or outside a conversation turn.
+ public ITurnCompleter? Completer { get; init; }
+
+ /// Gets where a cut of the running turn waits for the seal, or outside a conversation turn.
+ public TurnCutSlot? CutSlot { get; init; }
+
/// Reads the turn filed on a run's options by , or null when there is none.
internal static TurnInvocation? From(AgentRunOptions? options)
{
diff --git a/src/AgentCore.Application/Runtime/TurnRefusals.cs b/src/AgentCore.Application/Runtime/TurnRefusals.cs
new file mode 100644
index 00000000..38603efd
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/TurnRefusals.cs
@@ -0,0 +1,47 @@
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Runtime
+{
+ ///
+ /// Raises turn.refused for a turn that keeps none of its words, so the refusal leaves a log line and an audit
+ /// row even when the client that could read the error is gone.
+ ///
+ internal static class TurnRefusals
+ {
+ /// Another turn held the conversation past .
+ internal const string Busy = "busy";
+
+ /// The store refused the turn's words: another session saved that turn first.
+ internal const string Conflict = "conflict";
+
+ /// The client left while the turn waited for the conversation.
+ internal const string Gone = "gone";
+
+ /// The caller disposed the turn's run before it read the reply, so the model never ran.
+ internal const string Dropped = "dropped";
+
+ /// Raises the refusal, unless the conversation's chain already closed.
+ /// The conversation.
+ ///
+ /// The turn the session ran, or would have run, or for a turn refused before it read
+ /// the conversation: which index it would have taken is not known then.
+ ///
+ /// One of the tokens above.
+ internal static void Raise(ConversationSession session, int? turnIndex, string reason)
+ {
+ if (session.Events.HasEnded)
+ {
+ return;
+ }
+
+ _ = session.Events.Raise(
+ ConversationEventKind.TurnRefused,
+ session.Time.GetUtcNow(),
+ turnIndex,
+ payload: new Dictionary(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.RefusedReason] = reason,
+ });
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/TurnReplyResolution.cs b/src/AgentCore.Application/Runtime/TurnReplyResolution.cs
new file mode 100644
index 00000000..e705b593
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/TurnReplyResolution.cs
@@ -0,0 +1,104 @@
+using System.Text.Json;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Domain;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ /// Decides what one turn said, and why, from how its run ended.
+ internal static class TurnReplyResolution
+ {
+ /// Reads the reply, the failure, and the approvals still pending of one finished turn.
+ /// The conversation the turn belongs to.
+ /// The turn that just ran.
+ /// What the agent answered.
+ /// The cut the turn was sealed with, or .
+ /// What the pipeline layers reported, or .
+ /// The fault that threw out of the run, or .
+ /// Whether the turn keeps only the one line the user was shown, as a graph row does.
+ /// What the turn said, what the caller heard, and why they differ if they do.
+ internal static ReplyOutcome Resolve(
+ ConversationSession session,
+ ConversationTurn turn,
+ AgentResponse response,
+ TurnCut? interruption,
+ TurnDisposition? disposition,
+ Exception? fault,
+ bool callerFacing)
+ {
+ if (fault is null && disposition is { Fallback: FallbackCause.Faulted, FallbackFault: { } caught })
+ {
+ fault = caught;
+ }
+
+ bool isToolFault = fault is not null && ToolFaultMark.IsOn(fault);
+
+ string reply = ReplyText.From(response.Messages);
+
+ string generatedText = callerFacing ? reply : TurnMessages.AllText(response.Messages);
+
+ TimeSpan? interruptedAfter = null;
+
+ string? toolFault = isToolFault ? fault!.Message : null;
+
+ string? failure = fault is null
+ ? null
+ : (isToolFault ? ConversationSession.ToolFailureReason : ConversationSession.RunFaultReason)
+ + " " + fault.Message;
+
+ List approvals = ApprovalRequestsOf(response);
+
+ if (interruption is { } cut)
+ {
+ reply = (cut.ShownText ?? generatedText).Trim();
+ interruptedAfter = cut.Played ?? TimeSpan.Zero;
+ }
+ else if (approvals.Count == 0 && (failure is not null
+ || string.IsNullOrWhiteSpace(reply)
+ || disposition?.Fallback is FallbackCause.EmptyReply))
+ {
+ failure ??= ConversationSession.EmptyReplyReason;
+ reply = session.Compiled.FallbackReply;
+ }
+
+ if (fault is null && failure is not null)
+ {
+ session.Events.RaiseDiagnostic(ConversationEventKind.EmptyReply, session.Time.GetUtcNow(), turn.Index);
+ }
+ else if (fault is not null && !isToolFault)
+ {
+ Log.TurnRunFaulted(session.Logger, session.ConversationId, turn.Index, fault);
+ session.Events.RaiseDiagnostic(ConversationEventKind.RunFaulted, session.Time.GetUtcNow(), turn.Index);
+ }
+
+ return new ReplyOutcome(reply, generatedText, failure, toolFault, interruptedAfter, approvals, fault, isToolFault);
+ }
+
+ /// Reads the approval requests one finished turn still waits on.
+ /// What the agent answered.
+ /// One entry per request content, oldest first; empty when the turn asked nothing.
+ private static List ApprovalRequestsOf(AgentResponse response)
+ {
+ List approvals = [];
+ foreach (ToolApprovalRequestContent request in response.Messages
+ .SelectMany(message => message.Contents)
+ .OfType())
+ {
+ if (request.ToolCall is not FunctionCallContent call)
+ {
+ continue;
+ }
+
+ approvals.Add(new PendingApproval(
+ request.RequestId,
+ call.Name,
+ JsonSerializer.SerializeToElement(
+ call.Arguments ?? new Dictionary(StringComparer.Ordinal))));
+ }
+
+ return approvals;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/TurnRun.cs b/src/AgentCore.Application/Runtime/TurnRun.cs
new file mode 100644
index 00000000..e1a38886
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/TurnRun.cs
@@ -0,0 +1,110 @@
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Runtime
+{
+ /// One turn that has started: its index, and its reply as it arrives.
+ public sealed class TurnRun : IAsyncDisposable
+ {
+ private const int Unread = 0;
+
+ private const int Reading = 1;
+
+ private const int Disposed = 2;
+
+ private readonly IAsyncEnumerable _updates;
+
+ private readonly Action? _abandon;
+
+ private readonly Lock _gate = new();
+
+ private IAsyncEnumerator? _reader;
+
+ private int _state;
+
+ /// Creates a run over a reply the caller already has.
+ /// The index of the turn, which Cut names.
+ /// The reply, one update at a time.
+ /// is .
+ public TurnRun(int turnIndex, IAsyncEnumerable updates)
+ : this(turnIndex, updates, abandon: null)
+ {
+ }
+
+ /// The index of the turn.
+ /// The reply. Its enumeration holds the conversation, and ends the turn in a finally.
+ /// Ends a turn whose reply was never read, or when there is nothing to end.
+ internal TurnRun(int turnIndex, IAsyncEnumerable updates, Action? abandon)
+ {
+ ArgumentNullException.ThrowIfNull(updates);
+
+ TurnIndex = turnIndex;
+ _updates = updates;
+ _abandon = abandon;
+ }
+
+ /// Gets the index of the turn, which Cut names.
+ public int TurnIndex { get; }
+
+ ///
+ /// Gets the reply, one update at a time. Every update carries content. Enumerate it once: the turn
+ /// commits only as the enumeration runs and ends.
+ ///
+ public IAsyncEnumerable Updates => ReadAsync();
+
+ /// Frees the conversation this run holds. Safe to call more than once.
+ /// Call it from the flow that reads , not while a read is pending.
+ public async ValueTask DisposeAsync()
+ {
+ int before;
+ IAsyncEnumerator? reader;
+
+ lock (_gate)
+ {
+ before = _state;
+ reader = _reader;
+ _state = Disposed;
+ }
+
+ if (before == Unread)
+ {
+ _abandon?.Invoke();
+ }
+ else if (before == Reading && reader is not null)
+ {
+ await reader.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private async IAsyncEnumerable ReadAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ IAsyncEnumerator reader;
+
+ lock (_gate)
+ {
+ if (_state != Unread)
+ {
+ throw new InvalidOperationException(_state == Disposed
+ ? $"The run of turn {TurnIndex} was disposed, so its reply can no longer be read."
+ : $"The reply of turn {TurnIndex} is already being read.");
+ }
+
+ reader = _updates.GetAsyncEnumerator(cancellationToken);
+ _reader = reader;
+ _state = Reading;
+ }
+
+ try
+ {
+ while (await reader.MoveNextAsync().ConfigureAwait(false))
+ {
+ yield return reader.Current;
+ }
+ }
+ finally
+ {
+ await reader.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Sessions/Memory/HeldSession.cs b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs
new file mode 100644
index 00000000..28de9921
--- /dev/null
+++ b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs
@@ -0,0 +1,289 @@
+using AgentCore.Application.Runtime;
+
+namespace AgentCore.Application.Sessions.Memory
+{
+ /// One session that holds, with the idle timer that unloads it.
+ internal sealed class HeldSession : IDisposable
+ {
+ ///
+ /// How often the idle check polls for a background: child, before it is clamped to the idle
+ /// timeout in the constructor (a poll longer than the timeout it serves would never fire in time).
+ ///
+ internal static readonly TimeSpan BackgroundChildPollInterval = TimeSpan.FromSeconds(30);
+
+ private readonly Lock _gate = new();
+
+ private readonly TimeSpan _idleTimeout;
+
+ private readonly TimeSpan _backgroundPollInterval;
+
+ private readonly TimeProvider _time;
+
+ private readonly Action _expired;
+
+ private readonly ITimer _timer;
+
+ // A GetTimestamp value, not a DateTimeOffset, so that a wall-clock jump neither expires nor keeps a session.
+ private long _touched;
+
+ // Set when the timer is gone. The session is still handed out until it is also ended.
+ private bool _stopped;
+
+ // Set once the session expired or the host closed it: its close has begun. Nothing may hand it out after that.
+ private bool _ended;
+
+ // The running turn OnIdle last saw holding the session past its idle timeout. Tracked so the completion
+ // continuation that restarts the idle clock is attached once per turn, not once per poll.
+ private Task? _turnHeartbeat;
+
+ /// Holds one session and arms its idle timer.
+ /// The session to hold.
+ /// How long the session may stay untouched.
+ /// The clock the timer runs on.
+ /// Called once, off the gate, when the session expired.
+ internal HeldSession(ConversationSession session, TimeSpan idleTimeout, TimeProvider time, Action expired)
+ {
+ Session = session;
+ _idleTimeout = idleTimeout;
+ _backgroundPollInterval = idleTimeout < BackgroundChildPollInterval ? idleTimeout : BackgroundChildPollInterval;
+ _time = time;
+ _expired = expired;
+
+ lock (_gate)
+ {
+ _touched = time.GetTimestamp();
+ _timer = CreateTimer();
+ }
+ }
+
+ /// Gets the session held.
+ internal ConversationSession Session { get; }
+
+ ///
+ /// Restarts the idle clock, and stamps the workspace folder to match: every place that restarts one
+ /// restarts the other, so "held" always implies "stamped within the idle timeout" to another server
+ /// sharing the workspace root.
+ ///
+ /// once the session has ended. The caller must not hand it out then.
+ internal bool TryTouch()
+ {
+ bool restarted = false;
+
+ lock (_gate)
+ {
+ if (_ended)
+ {
+ return false;
+ }
+
+ if (!_stopped)
+ {
+ _touched = _time.GetTimestamp();
+ _ = _timer.Change(NextDueLocked(), Timeout.InfiniteTimeSpan);
+ restarted = true;
+ }
+ }
+
+ if (restarted)
+ {
+ Session.Lifetime.TouchWorkspace();
+ }
+
+ return true;
+ }
+
+ /// Gets whether the session's close has begun, from its idle timer or from the host.
+ internal bool IsEnded
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _ended;
+ }
+ }
+ }
+
+ /// Ends the session because the host closes it. Its timer never fires after this.
+ ///
+ /// when the session had already ended, and whoever ended it runs the close.
+ ///
+ internal bool TryEnd()
+ {
+ lock (_gate)
+ {
+ if (_ended)
+ {
+ return false;
+ }
+
+ _ended = true;
+ StopLocked();
+ return true;
+ }
+ }
+
+ /// Stops the timer. The session stays held, and a lookup still finds it.
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ StopLocked();
+ }
+ }
+
+ private void OnIdle()
+ {
+ Task? turn = null;
+ Task? attachHeartbeatTo = null;
+ bool stopped = false;
+ bool rescheduled = false;
+ bool touchWorkspace = false;
+
+ lock (_gate)
+ {
+ if (_stopped)
+ {
+ stopped = true;
+ }
+ else
+ {
+ bool pollBackground = Session.Lifetime.MayHaveBackgroundChildren;
+
+ // A touch can re-arm the timer after it came due but before this callback ran.
+ TimeSpan idle = _time.GetElapsedTime(_touched);
+ if (idle < _idleTimeout)
+ {
+ // A background-capable document is watched the whole window through, at the shorter
+ // cadence, rather than only once the window is up: a child running right now is
+ // activity, read exactly like a touch, so the window does not close under it later for
+ // having missed it.
+ if (pollBackground && Session.Lifetime.HasRunningBackgroundChild())
+ {
+ _touched = _time.GetTimestamp();
+ touchWorkspace = true;
+ }
+
+ _ = _timer.Change(pollBackground ? _backgroundPollInterval : _idleTimeout - idle, Timeout.InfiniteTimeSpan);
+ rescheduled = true;
+ }
+ else
+ {
+ turn = Session.Cuts.RunningTurn();
+ if (turn is null)
+ {
+ _turnHeartbeat = null;
+
+ if (pollBackground && Session.Lifetime.HasRunningBackgroundChild())
+ {
+ // Still going: treat it as the touch it is, and keep polling at the shorter
+ // cadence rather than sleeping the full idle timeout again.
+ _touched = _time.GetTimestamp();
+ touchWorkspace = true;
+ rescheduled = true;
+ _ = _timer.Change(_backgroundPollInterval, Timeout.InfiniteTimeSpan);
+ }
+ else
+ {
+ _ended = true;
+ StopLocked();
+ }
+ }
+ else
+ {
+ // The turn alone has held the session past its idle timeout. Stamped now, and
+ // again at about half the idle timeout for as long as it keeps running, so a turn
+ // that runs on well past this point is never mistaken for dead by another server
+ // sharing the workspace root. The completion continuation below — which restarts
+ // the idle clock the moment the turn frees the slot — is attached once per turn,
+ // not once per poll, so a long turn does not stack a callback on every tick.
+ touchWorkspace = true;
+
+ if (!ReferenceEquals(_turnHeartbeat, turn))
+ {
+ _turnHeartbeat = turn;
+ attachHeartbeatTo = turn;
+ }
+
+ rescheduled = true;
+ _ = _timer.Change(_idleTimeout / 2, Timeout.InfiniteTimeSpan);
+ }
+ }
+ }
+ }
+
+ if (stopped)
+ {
+ return;
+ }
+
+ // The workspace stamp is disk I/O; it runs after the gate is released so it never holds up a
+ // concurrent TryTouch, IsEnded, or TryEnd call on the same session.
+ if (touchWorkspace)
+ {
+ Session.Lifetime.TouchWorkspace();
+ }
+
+ // The idle clock restarts when the turn frees the slot. Closing now would wait on the turn anyway.
+ _ = (attachHeartbeatTo?.ContinueWith(
+ static (_, state) => ((HeldSession)state!).TryTouch(),
+ this,
+ CancellationToken.None,
+ TaskContinuationOptions.None,
+ TaskScheduler.Default));
+
+ if (rescheduled)
+ {
+ return;
+ }
+
+ if (turn is null)
+ {
+ _expired(this);
+ }
+ }
+
+ ///
+ /// How long the timer should sleep from a fresh touch: the poll cadence for a document that may have
+ /// background children, or the full idle timeout for one that cannot.
+ ///
+ private TimeSpan NextDueLocked()
+ {
+ return Session.Lifetime.MayHaveBackgroundChildren ? _backgroundPollInterval : _idleTimeout;
+ }
+
+ private void StopLocked()
+ {
+ if (!_stopped)
+ {
+ _stopped = true;
+ _timer.Dispose();
+ }
+ }
+
+ private ITimer CreateTimer()
+ {
+ // The timer would otherwise capture the context of the request that opened the session. The expiry
+ // would then run an idle timeout later inside that request's trace and logging scopes.
+ bool restoreFlow = false;
+ try
+ {
+ if (!ExecutionContext.IsFlowSuppressed())
+ {
+ _ = ExecutionContext.SuppressFlow();
+ restoreFlow = true;
+ }
+
+ return _time.CreateTimer(
+ static state => ((HeldSession)state!).OnIdle(), this, NextDueLocked(), Timeout.InfiniteTimeSpan);
+ }
+ finally
+ {
+ if (restoreFlow)
+ {
+ ExecutionContext.RestoreFlow();
+ }
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs
index 9c04e2fd..5679b84c 100644
--- a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs
+++ b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs
@@ -1,162 +1,278 @@
-using AgentCore.Domain.Audit;
using System.Collections.Concurrent;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Diagnostics;
using AgentCore.Application.Ports;
using AgentCore.Application.Runtime;
+using Microsoft.Extensions.Logging;
namespace AgentCore.Application.Sessions.Memory
{
///
/// The default . It holds every session in this process.
///
- ///
- ///
- /// This does not survive a restart and does not span instances. A process that stops loses every
- /// call it was holding, and a second instance behind a load balancer never sees the sessions of the
- /// first. A deployment that needs either registers another before
- /// AddAgentCore, and this steps aside.
- ///
- ///
- /// A session is dropped when it has been untouched for the idle timeout. The voice path does not
- /// wait for that — the socket closing is a real end, and it closes the conversation itself. The timeout is
- /// for the text path, where a caller who simply stops replying never reaches a terminal stage and
- /// would otherwise be held for the life of the process.
- ///
- ///
- public sealed class InMemoryConversationSessions : IConversationSessions
+ public sealed class InMemoryConversationSessions : IConversationSessions, IDisposable
{
/// The idle timeout a host gets when it names none.
- public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(30);
+ public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(10);
+
+ // The longest due time ITimer.Change accepts.
+ private static readonly TimeSpan MaxIdleTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+
+ private readonly ConcurrentDictionary _slots = new(StringComparer.Ordinal);
+
+ private readonly IReadOnlyDictionary _factories;
- private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal);
- private readonly IConversationSessionFactory _factory;
private readonly TimeSpan _idleTimeout;
+
private readonly TimeProvider _time;
+ private volatile bool _disposed;
+
/// Creates the store.
- /// Builds the session of a conversation that is not held yet.
- /// How long an untouched session is kept. It slides on every read.
- /// The clock the idle timeout is measured on.
+ /// Builds the session of a conversation that is not held yet, keyed by entry name.
+ ///
+ /// How long an untouched session is kept. It slides on every read, and restarts when a running turn ends.
+ /// At most about 49 days.
+ ///
+ /// The clock the idle timers run on.
+ ///
+ /// is not positive, or is longer than a timer can wait.
+ ///
public InMemoryConversationSessions(
- IConversationSessionFactory factory, TimeSpan idleTimeout, TimeProvider timeProvider)
+ IReadOnlyDictionary factories, TimeSpan idleTimeout, TimeProvider timeProvider)
{
- ArgumentNullException.ThrowIfNull(factory);
+ ArgumentNullException.ThrowIfNull(factories);
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(idleTimeout, TimeSpan.Zero);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(idleTimeout, MaxIdleTimeout);
- _factory = factory;
+ _factories = factories;
_idleTimeout = idleTimeout;
_time = timeProvider;
}
- /// Gets how many sessions this holds.
- public int Count => _sessions.Count;
+ /// Gets how many live sessions this holds. A session still being built, or already closing, is not counted.
+ public int Count => _slots.Values.Count(static slot => slot.TryGetHeld(out HeldSession? held) && !held.IsEnded);
///
- public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default)
+ /// This store was disposed.
+ /// is empty, or names no declared entry.
+ public async ValueTask GetOrOpenAsync(
+ string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default)
{
- cancellationToken.ThrowIfCancellationRequested();
+ ArgumentException.ThrowIfNullOrEmpty(entry);
+ IConversationSessionFactory factory = FactoryFor(entry);
+
+ // Minted here rather than by the factory, so a new conversation reserves its id like any other.
+ string id = string.IsNullOrWhiteSpace(conversationId) ? Guid.NewGuid().ToString("N") : conversationId;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (!_slots.TryGetValue(id, out SessionSlot? slot))
+ {
+ SessionSlot reserved = new(entry);
+ if (_slots.TryAdd(id, reserved))
+ {
+ return Build(id, reserved, factory, state);
+ }
+
+ continue;
+ }
+
+ // A closing session's folder is about to be deleted, whichever entry held it.
+ if (slot.TryGetHeld(out HeldSession? closing) && closing.IsEnded)
+ {
+ await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false);
+ continue;
+ }
- ConversationSession session = _factory.Create(conversationId);
- _sessions[session.ConversationId] = new Entry(session, _time.GetUtcNow());
- return ValueTask.FromResult(session);
+ if (!string.Equals(slot.Entry, entry, StringComparison.Ordinal))
+ {
+ throw new ConversationInUseException(id, slot.Entry, entry);
+ }
+
+ HeldSession held = await slot.Opened.WaitAsync(cancellationToken).ConfigureAwait(false);
+ if (held.TryTouch())
+ {
+ return held.Session;
+ }
+
+ await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
}
///
- public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default)
+ /// is empty.
+ public ValueTask TryGetAsync(
+ string entry, string conversationId, CancellationToken cancellationToken = default)
{
+ ArgumentException.ThrowIfNullOrEmpty(entry);
ArgumentNullException.ThrowIfNull(conversationId);
cancellationToken.ThrowIfCancellationRequested();
- if (!_sessions.TryGetValue(conversationId, out Entry? entry))
- {
- return ValueTask.FromResult(null);
- }
-
- _ = _sessions.TryUpdate(conversationId, entry with { Touched = _time.GetUtcNow() }, entry);
- return ValueTask.FromResult(entry.Session);
+ return ValueTask.FromResult(
+ _slots.TryGetValue(conversationId, out SessionSlot? slot)
+ && string.Equals(slot.Entry, entry, StringComparison.Ordinal)
+ && slot.TryGetHeld(out HeldSession? held)
+ && held.TryTouch()
+ ? held.Session
+ : null);
}
///
- public async ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default)
+ /// is empty.
+ public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default)
{
+ ArgumentException.ThrowIfNullOrEmpty(entry);
ArgumentNullException.ThrowIfNull(conversationId);
- // Taken out first so a second caller cannot be handed a session that is already draining,
- // then flushed while this method still holds the only reference to it.
- if (_sessions.TryRemove(conversationId, out Entry? entry))
+ if (!_slots.TryGetValue(conversationId, out SessionSlot? slot)
+ || !string.Equals(slot.Entry, entry, StringComparison.Ordinal)
+ || !slot.TryGetHeld(out HeldSession? held))
{
- // A store that throws on flush must not leave the session's shells running with
- // nothing left to hold a reference to them.
- try
- {
- await entry.Session.FlushTranscriptAsync().ConfigureAwait(false);
- }
- finally
- {
- await entry.Session.DisposeAsync().ConfigureAwait(false);
- }
+ return;
+ }
+
+ if (held.TryEnd())
+ {
+ await TearDownAsync(conversationId, slot, held).ConfigureAwait(false);
+ }
+ else
+ {
+ await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false);
}
}
- /// Closes every session that has been untouched for the idle timeout.
- /// Cancels the sweep.
- /// A task that completes once every expired session is closed.
- ///
- /// One or more sessions could not be ended. The sweep still visited every other session first.
- ///
- ///
- /// This goes through and not the dictionary, so an expiring session
- /// hands over its words on the way out exactly as one the host closed by hand.
- ///
- public async ValueTask SweepAsync(CancellationToken cancellationToken = default)
+ ///
+ /// Sweeps for a folder a crashed process left behind. See
+ /// for what counts as one, and why. Meant to run once, at
+ /// boot, before this owner has opened any session of its own; a folder this owner already holds by the
+ /// time this runs is skipped regardless of its stamp, so a caller that runs it later never sweeps its
+ /// own live session.
+ ///
+ /// The workspace root a host bound.
+ /// Where a failed list or delete is logged, at Warning. May be .
+ /// is empty.
+ internal void SweepWorkspaceRoot(string root, ILogger? logger)
{
- DateTimeOffset cutoff = _time.GetUtcNow() - _idleTimeout;
- List? faults = null;
+ new WorkspaceRootSweeper(_idleTimeout, _time).Sweep(root, id => _slots.ContainsKey(id), logger);
+ }
- foreach ((string? conversationId, Entry? entry) in _sessions.ToArray())
- {
- cancellationToken.ThrowIfCancellationRequested();
+ /// Stops every idle timer. A build or a close already running finishes, and wakes its waiters.
+ public void Dispose()
+ {
+ _disposed = true;
- if (entry.Touched > cutoff)
+ foreach (SessionSlot slot in _slots.Values)
+ {
+ if (slot.TryGetHeld(out HeldSession? held))
{
- continue;
+ held.Dispose();
}
+ }
+ }
- // One session that will not end must not leave every session after it in the
- // dictionary for the life of the process, which is the leak this sweep exists to stop.
+ ///
+ /// The one close routine every way out runs: flush, dispose (background children, then shells), delete
+ /// the workspace folder. A store that throws on flush must not leave the shells running with nothing
+ /// left to hold a reference to them, and a dispose that throws must not leave the folder undeleted.
+ ///
+ private static async ValueTask CloseSessionAsync(ConversationSession session)
+ {
+ try
+ {
try
{
- await ExpireAsync(conversationId, entry.Session, cancellationToken).ConfigureAwait(false);
+ await session.FlushTranscriptAsync().ConfigureAwait(false);
}
- catch (Exception fault) when (fault is not OperationCanceledException)
+ finally
{
- (faults ??= []).Add(fault);
+ await session.DisposeAsync().ConfigureAwait(false);
}
}
+ finally
+ {
+ session.Lifetime.DeleteWorkspace();
+ }
+ }
+
+ /// Builds the session of an id this caller reserved, and hands it to every caller waiting on the slot.
+ private ConversationSession Build(
+ string conversationId, SessionSlot slot, IConversationSessionFactory factory, ConversationSessionState? state)
+ {
+ HeldSession held;
+ try
+ {
+ ConversationSession session = factory.Create(conversationId, state);
+ held = new(session, _idleTimeout, _time, expired => Expire(conversationId, slot, expired));
+ }
+ catch (Exception fault)
+ {
+ _ = _slots.TryRemove(KeyValuePair.Create(conversationId, slot));
+ slot.Fail(fault);
+ throw;
+ }
+
+ slot.Open(held);
- if (faults is not null)
+ // A Dispose that ran before the slot was opened did not see this timer.
+ if (_disposed)
{
- throw new AggregateException(faults);
+ held.Dispose();
}
+
+ return held.Session;
+ }
+
+ private IConversationSessionFactory FactoryFor(string entry)
+ {
+ return _factories.TryGetValue(entry, out IConversationSessionFactory? factory)
+ ? factory
+ : throw new ArgumentException(
+ $"The entry '{entry}' is not declared. Valid entries: {string.Join(", ", _factories.Keys)}.",
+ nameof(entry));
+ }
+
+ /// Starts the unload of one expired session. Its idle timer ended it, so no other close runs.
+ private void Expire(string conversationId, SessionSlot slot, HeldSession held)
+ {
+ _ = ExpireAsync(conversationId, slot, held);
}
- /// Ends one expired conversation, chain first and session second.
- ///
- /// §11 item 6 makes conversation.ended the last event of every conversation, and expiry is a way a conversation
- /// ends. Nothing else writes it here: the relay closes its own chain from the socket and the
- /// turn loop closes its own from a terminal stage, so a caller who simply stops replying is the
- /// one ending that would otherwise leave a chain with no end.
- /// is idempotent, so a session that already closed its chain keeps the reason it wrote.
- /// The reason is because the closed set of §6 holds no
- /// reason for an abandoned conversation; see the amendment owed on that enum.
- ///
- private async ValueTask ExpireAsync(
- string conversationId, ConversationSession session, CancellationToken cancellationToken)
+ /// Unloads one expired conversation. It writes no end event; the conversation stays open.
+ private async Task ExpireAsync(string conversationId, SessionSlot slot, HeldSession held)
{
- _ = session.EndConversation(ConversationEndReason.Faulted);
- await CloseAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ // Nothing awaits an expiry, so nothing may escape it.
+#pragma warning disable CA1031
+ try
+ {
+ await TearDownAsync(conversationId, slot, held).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ SessionOwnerLog.SessionExpiryFailed(held.Session.Logger, conversationId, exception);
+ }
+#pragma warning restore CA1031
}
- private sealed record Entry(ConversationSession Session, DateTimeOffset Touched);
+ ///
+ /// Tears down a session whose close this caller began, then gives up its slot. The slot stays taken until
+ /// the teardown is done, so an open of the id waits instead of racing the folder delete.
+ ///
+ private async Task TearDownAsync(string conversationId, SessionSlot slot, HeldSession held)
+ {
+ try
+ {
+ await CloseSessionAsync(held.Session).ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _slots.TryRemove(KeyValuePair.Create(conversationId, slot));
+ slot.Release();
+ }
+ }
}
}
diff --git a/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs b/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs
new file mode 100644
index 00000000..fa4daff1
--- /dev/null
+++ b/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs
@@ -0,0 +1,61 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace AgentCore.Application.Sessions.Memory
+{
+ ///
+ /// The place one conversation id takes in . An open reserves it
+ /// before the session is built, and it is given up only once the build failed or the session's teardown is
+ /// done, so no second session of the id can start while the first is still being built or torn down.
+ ///
+ /// The entry that reserved the id.
+ internal sealed class SessionSlot(string entry)
+ {
+ private readonly TaskCompletionSource _opened = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private volatile HeldSession? _held;
+
+ /// Gets the entry that reserved the id.
+ internal string Entry { get; } = entry;
+
+ /// Gets a task that completes with the held session once it is built, or faults with what the build threw.
+ internal Task Opened => _opened.Task;
+
+ ///
+ /// Gets a task that completes once this slot is out of the dictionary: its build failed, or its session's
+ /// teardown is done.
+ ///
+ internal Task Released => _released.Task;
+
+ /// Reads the held session, once it is built.
+ internal bool TryGetHeld([NotNullWhen(true)] out HeldSession? held)
+ {
+ held = _held;
+ return held is not null;
+ }
+
+ /// Hands the built session to every caller waiting on .
+ internal void Open(HeldSession held)
+ {
+ _held = held;
+ _opened.SetResult(held);
+ }
+
+ /// Hands the build's fault to every caller waiting on . Call it once the slot is out of the dictionary.
+ internal void Fail(Exception fault)
+ {
+ _opened.SetException(fault);
+
+ // The builder rethrows the fault itself, so a slot nobody else waited on must not report it again as unobserved.
+ _ = _opened.Task.Exception;
+ _released.SetResult();
+ }
+
+ /// Wakes every caller waiting for the teardown. Call it once the slot is out of the dictionary.
+ internal void Release()
+ {
+ _released.SetResult();
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs
new file mode 100644
index 00000000..7f07eb63
--- /dev/null
+++ b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs
@@ -0,0 +1,151 @@
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Runtime;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Sessions.Memory
+{
+ ///
+ /// The boot sweep runs once, at startup, over its workspace root.
+ ///
+ internal sealed class WorkspaceRootSweeper
+ {
+ private readonly TimeSpan _idleTimeout;
+
+ private readonly TimeProvider _time;
+
+ /// Creates the sweeper over one owner's idle timeout and clock.
+ /// The owner's idle timeout: how old a folder or a lone marker must be to sweep.
+ /// The clock the owner's idle timers run on.
+ internal WorkspaceRootSweeper(TimeSpan idleTimeout, TimeProvider time)
+ {
+ _idleTimeout = idleTimeout;
+ _time = time;
+ }
+
+ ///
+ /// Deletes every folder directly under that has a sibling marker
+ /// () and whose last-write time is at least the idle
+ /// timeout old: a folder a crashed process left behind. The marker goes with it. A folder without a
+ /// marker is never a folder made, so it is left alone
+ /// untouched, whatever its stamp — a workspace root that happens to point at a shared or pre-existing
+ /// folder must lose nothing this library did not put there. A marker whose folder is already gone is a
+ /// leftover of its own — from a folder deleted out of band, say — and is deleted once its own stamp is
+ /// old enough, by the same cutoff. Meant to run once, at boot, before the owner has opened any session
+ /// of its own; a folder the owner already holds by the time this runs is skipped regardless of its
+ /// stamp, so a caller that runs it later never sweeps a live session. A folder that is a symbolic link
+ /// is skipped untouched, so this never follows a link out of the root. Never throws: a folder, a
+ /// marker, or the root itself, that cannot be listed, inspected, or deleted is logged and left for the
+ /// next boot.
+ ///
+ /// The workspace root a host bound.
+ /// Answers whether a conversation id is already held live by the calling owner.
+ /// Where a failed list or delete is logged, at Warning. May be .
+ /// is empty.
+ internal void Sweep(string root, Func isLive, ILogger? logger)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(root);
+
+ string[] folders;
+ string[] markers;
+ try
+ {
+ // Eager, not EnumerateDirectories/EnumerateFiles: a lazy sequence would run its own OS-level
+ // listing calls outside this try, on whatever caller code enumerates it, so a fault partway
+ // through a large or unreachable folder would escape uncaught instead of being logged here.
+ folders = Directory.GetDirectories(root);
+ markers = Directory.GetFiles(root, "*" + ConversationWorkspace.MarkerFileName);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
+ {
+ if (logger is not null)
+ {
+ SessionOwnerLog.WorkspaceSweepListFailed(logger, root, exception);
+ }
+
+ return;
+ }
+
+ // No margin for clock skew: a root shared by several servers is the host's to coordinate.
+ DateTime cutoff = _time.GetUtcNow().UtcDateTime - _idleTimeout;
+
+ HashSet liveFolderNames = new(folders.Select(folder => Path.GetFileName(folder)!), StringComparer.Ordinal);
+
+ foreach (string folder in folders)
+ {
+ if (isLive(Path.GetFileName(folder)!))
+ {
+ continue;
+ }
+
+ try
+ {
+ DirectoryInfo info = new(folder);
+ if (info.LinkTarget is not null)
+ {
+ continue;
+ }
+
+ string marker = ConversationWorkspace.MarkerPathFor(folder);
+ if (!File.Exists(marker))
+ {
+ continue;
+ }
+
+ if (info.LastWriteTimeUtc <= cutoff)
+ {
+ Directory.Delete(folder, recursive: true);
+ TryDeleteMarker(marker, logger);
+ }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
+ {
+ if (logger is not null)
+ {
+ SessionOwnerLog.WorkspaceSweepDeleteFailed(logger, folder, exception);
+ }
+ }
+ }
+
+ foreach (string marker in markers)
+ {
+ string id = Path.GetFileName(marker)[..^ConversationWorkspace.MarkerFileName.Length];
+ if (liveFolderNames.Contains(id))
+ {
+ // Has a folder: handled above, alongside it, whichever way that went.
+ continue;
+ }
+
+ try
+ {
+ if (File.GetLastWriteTimeUtc(marker) <= cutoff)
+ {
+ TryDeleteMarker(marker, logger);
+ }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FileNotFoundException)
+ {
+ if (logger is not null)
+ {
+ SessionOwnerLog.WorkspaceSweepDeleteFailed(logger, marker, exception);
+ }
+ }
+ }
+ }
+
+ /// Deletes one workspace marker file. A failure is logged, never thrown.
+ private static void TryDeleteMarker(string marker, ILogger? logger)
+ {
+ try
+ {
+ File.Delete(marker);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ if (logger is not null)
+ {
+ SessionOwnerLog.WorkspaceMarkerDeleteFailed(logger, marker, exception);
+ }
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/State/StateDocument.cs b/src/AgentCore.Application/State/StateDocument.cs
index 309c011f..6d48f2d5 100644
--- a/src/AgentCore.Application/State/StateDocument.cs
+++ b/src/AgentCore.Application/State/StateDocument.cs
@@ -136,12 +136,6 @@ private static bool IsMember(IReadOnlyList members, JsonNode? value)
/// Reads the declared slots a writer has actually filled, for the durable blob.
/// A copy. An unfilled slot is absent, which is what keeps unfilled and filled-default apart.
- ///
- /// Not : that one fills every declared slot with its default and adds the
- /// three reserved slots, which is right for a guard and wrong for a blob. Restoring a default as
- /// though a writer had chosen it would lose the difference
- /// exists to keep.
- ///
public IReadOnlyDictionary WrittenSlots()
{
Dictionary written = new(_written.Count, StringComparer.Ordinal);
@@ -154,6 +148,12 @@ private static bool IsMember(IReadOnlyList members, JsonNode? value)
return written;
}
+ /// Empties every declared slot a writer filled, so each reads as its declared default again.
+ internal void ClearWrittenSlots()
+ {
+ _written.Clear();
+ }
+
private JsonValue? ReadReserved(string slot)
{
if (string.Equals(slot, ReservedStateSlots.Stage, StringComparison.Ordinal))
diff --git a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs
index 832a521d..d4e1bf08 100644
--- a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs
+++ b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs
@@ -1,7 +1,5 @@
-using System.Runtime.CompilerServices;
using AgentCore.Application.Conversation;
using AgentCore.Application.Conversation.Memory;
-using AgentCore.Application.Diagnostics;
using AgentCore.Application.Ports;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -11,21 +9,36 @@
namespace AgentCore.Application.Transcript
{
///
- /// Reports one store 1 write that was dropped, so the conversation can raise a diagnostic for it.
+ /// Store the words of a conversation, held for the session by the provider and written through to a backing store.
///
- internal delegate void TranscriptWriteDropped(int turnIndex, Exception exception);
-
- ///
- /// Store 1: the words of a conversation, held for the session by the provider and written through to a backing store.
- ///
- /// Creates the provider over a backing store.
- internal sealed class AgentCoreChatHistoryProvider(IConversationStore? store = null, ILogger? logger = null) : ChatHistoryProvider
+ internal sealed class AgentCoreChatHistoryProvider : ChatHistoryProvider
{
- private readonly ConditionalWeakTable _gates = [];
+ ///
+ /// The state bag key that binds a session to its conversation. A session without it reads nothing, stages
+ /// nothing and commits nothing, whatever else its bag carries (design probe G4).
+ ///
+ internal const string ConversationKey = "agentcore.history";
+
+ private static readonly IReadOnlyList Keys = [ConversationKey];
- private readonly IConversationStore _store = store ?? new InMemoryConversationStore();
+ private readonly ConversationGates _gates;
- private readonly ILogger _logger = logger ?? NullLogger.Instance;
+ private readonly ConversationWrites _writes;
+
+ /// Creates the provider over a backing store.
+ /// Store 1, or for one kept in this process.
+ /// Where refused writes and cuts are logged.
+ public AgentCoreChatHistoryProvider(IConversationStore? store = null, ILogger? logger = null)
+ : base(storeInputRequestMessageFilter: ExternalOnly)
+ {
+ ILogger log = logger ?? NullLogger.Instance;
+ IConversationStore words = store ?? new InMemoryConversationStore();
+ _gates = new ConversationGates(words, log);
+ _writes = new ConversationWrites(_gates, words, log);
+ }
+
+ ///
+ public override IReadOnlyList StateKeys => Keys;
///
/// Opens a conversation on one session: names it, reads back what it already said, and says where a
@@ -37,7 +50,7 @@ internal sealed class AgentCoreChatHistoryProvider(IConversationStore? store = n
/// What store 1 already holds for this conversation, which is empty for a conversation that is new. A second
/// session of one conversation is handed the first session's words here, and nowhere else.
///
- /// Where a dropped store 1 write is reported, if anywhere.
+ /// Where a dropped store 1 write is counted, if anywhere.
///
/// How far the conversation had got, from store 0's own counters — or the zero marks of a conversation that has
/// never spoken. The words cannot say this on their own, because an edit deletes the rows that
@@ -48,50 +61,63 @@ public int BeginConversation(
AgentSession session,
string conversationId,
IReadOnlyList spoken,
- TranscriptWriteDropped? report = null,
+ ITranscriptLossCounter? losses = null,
TranscriptMarks marks = default)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentException.ThrowIfNullOrEmpty(conversationId);
ArgumentNullException.ThrowIfNull(spoken);
+ session.StateBag.SetValue(ConversationKey, conversationId);
+
return UnderLock(
session,
(transcript, gate) =>
{
transcript.ConversationId = conversationId;
- gate.Dropped = report;
+ gate.Losses = losses;
return transcript.Resume(spoken, marks);
});
}
- /// Reads the ordinal the session expects the conversation's next row to take.
+ /// Reads where the session's words stand: the writes queued so far, the revision, and the next ordinal, together.
/// The session this conversation runs on.
///
- /// The next free ordinal as the session counts it. Equal to store 0's own counter unless a row was
- /// written by someone else, or one of this session's own writes was dropped.
+ /// The position. Once completes, store 0's counter equals
+ /// unless a row was written by someone else, or one of this
+ /// session's own writes was dropped.
///
- public int NextOrdinal(AgentSession session)
+ public TranscriptPosition Position(AgentSession session)
{
ArgumentNullException.ThrowIfNull(session);
- return UnderLock(session, static transcript => transcript.NextOrdinal);
+ return UnderLock(
+ session,
+ static (transcript, gate) => new TranscriptPosition(gate.Writes, transcript.Revision, transcript.NextOrdinal));
}
- /// Replaces the conversation's live history with what store 1 holds.
+ ///
+ /// Replaces the conversation's live history with what store 1 holds, unless the session's words moved since
+ /// the read began. Words that moved hold a write store 1 may not have yet, and replacing them would lose it.
+ ///
/// The session this conversation runs on.
- /// The rows store 1 read for the session, as takes them.
- /// The next free ordinal of the conversation, from store 0's own counter.
- public void Resync(AgentSession session, IReadOnlyList rows, int nextOrdinal)
+ /// What store 1 held, and the revision the read began at.
+ /// when the words moved and nothing was replaced.
+ public bool TryResync(AgentSession session, TranscriptCatchUp catchUp)
{
ArgumentNullException.ThrowIfNull(session);
- ArgumentNullException.ThrowIfNull(rows);
+ ArgumentNullException.ThrowIfNull(catchUp);
- _ = UnderLock(
+ return UnderLock(
session,
transcript =>
{
- transcript.Resync(rows, nextOrdinal);
+ if (transcript.Revision != catchUp.Revision)
+ {
+ return false;
+ }
+
+ transcript.Resync(catchUp.Rows, catchUp.NextOrdinal);
return true;
});
}
@@ -130,25 +156,11 @@ public bool Compact(AgentSession session, ChatMessage summary, int coversUpTo, i
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(summary);
- return UnderLock(
- session,
- (transcript, gate) =>
- {
- if (transcript.Compact(summary, coversUpTo, revision) is not { } row)
- {
- return false;
- }
-
- ConversationMessageDraft draft = new(row.TurnIndex, row.Content, row.MessageId) { CoversUpTo = row.CoversUpTo };
- gate.Enqueue(() => new ValueTask(_store.AppendAsync(
- transcript.ConversationId, [draft], state: null, CancellationToken.None).AsTask()));
-
- return true;
- });
+ return _writes.Compact(session, summary, coversUpTo, revision);
}
///
- /// Whether can cut an edit hanging off one message from what the session
+ /// Whether can cut an edit hanging off one message from what the session
/// holds. It cannot when the cut would reach a row the summary stands in for; every row must be read back first.
///
/// The session this conversation runs on.
@@ -161,7 +173,7 @@ public bool CanTruncateFrom(AgentSession session, string? parentMessageId)
}
///
- /// Stamps the turn the next run belongs to.
+ /// Stamps the turn the next run belongs to, and drops anything an earlier turn staged and never committed.
///
public void BeginTurn(AgentSession session, int turnIndex)
{
@@ -169,9 +181,10 @@ public void BeginTurn(AgentSession session, int turnIndex)
_ = UnderLock(
session,
- transcript =>
+ (transcript, gate) =>
{
transcript.BeginTurn(turnIndex);
+ gate.Staged.Clear();
return true;
});
}
@@ -186,47 +199,32 @@ public IReadOnlyList Read(AgentSession session)
return UnderLock(session, static transcript => transcript.Read());
}
- /// Adds one finished turn's messages to the conversation, and the state that follows them.
+ /// Reads what the framework staged for the running turn and no commit has taken yet, oldest first.
/// The session this conversation runs on.
- /// The messages the turn produced.
- ///
- /// The state to store beside the words, or to store none. A value, read
- /// by the caller before it conversations: everything the turn writes to the state document — the clock
- /// fields, the counters, the stage and whether the machine finished — is already final when the
- /// caller enters its commit lock, and the late barge-in path that runs after this conversation amends
- /// the record of the turn without touching any of it. So there is nothing later to wait for.
- ///
- ///
- /// What the caller calls the first of these messages, or to name it in the
- /// append. Only the first: the rest are this conversation's own words, which no caller had a name for.
- ///
- ///
- /// The name the last of these messages was written under, so the caller can be told what to hang
- /// its next edit off. It is only when nothing was written.
- ///
- public string? AppendTurn(
- AgentSession session,
- IReadOnlyList messages,
- ConversationSessionState? state = null,
- string? firstMessageId = null)
+ public IReadOnlyList Staged(AgentSession session)
{
ArgumentNullException.ThrowIfNull(session);
- ArgumentNullException.ThrowIfNull(messages);
- return messages.Count == 0
- ? null
- : UnderLock(
- session,
- (transcript, gate) =>
- {
- IReadOnlyList rows = transcript.Append(messages, firstMessageId);
+ return UnderLock(session, static (_, gate) => (IReadOnlyList)[.. gate.Staged]);
+ }
- ConversationMessageDraft[] drafts = [.. rows.Select(row => new ConversationMessageDraft(row.TurnIndex, row.Content, row.MessageId))];
+ ///
+ /// Seals one turn: the one durable append of that turn, holding its words and the state that follows
+ /// them. Whatever the framework staged for the turn is taken here, and nothing stays staged.
+ ///
+ /// The session this conversation runs on.
+ /// The turn being sealed.
+ ///
+ /// What the user's message and the last message were written under, and whether the store kept them, or
+ /// when the session is bound to no conversation and nothing was written. The reply id
+ /// is when the user's message is all the turn wrote.
+ ///
+ public TurnWrite? CommitTurn(AgentSession session, TurnCommit commit)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(commit);
- gate.Enqueue(() => new ValueTask(_store.AppendAsync(
- transcript.ConversationId, drafts, state, CancellationToken.None).AsTask()));
- return rows[^1].MessageId;
- });
+ return IsBound(session) ? _writes.Commit(session, commit) : null;
}
/// Withdraws everything the conversation said after one message, because a caller replaced it.
@@ -236,6 +234,7 @@ public IReadOnlyList Read(AgentSession session)
/// to withdraw the whole conversation, which is what an edit of its first message
/// asks for.
///
+ /// Cancels the read that judges the session's lost writes before the cut.
///
/// The turns the withdrawal took, or when nothing went. Nothing goes when
/// the conversation holds no message of that name — a caller naming a message this host never stored —
@@ -245,91 +244,41 @@ public IReadOnlyList Read(AgentSession session)
/// The cut reaches a row the summary stands in for. Ask first, and read every
/// row back when it says no.
///
- public WithdrawnTurns? TruncateFrom(AgentSession session, string? parentMessageId)
+ public ValueTask TruncateFromAsync(AgentSession session, string? parentMessageId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
- return UnderLock(
- session,
- (transcript, gate) =>
- {
- int from;
- if (parentMessageId is null)
- {
- from = 0;
- }
- else if (transcript.OrdinalOf(parentMessageId) is { } parent)
- {
- from = parent + 1;
- }
- else
- {
- return (WithdrawnTurns?)null;
- }
-
- if (transcript.TruncateFrom(from) is not { } withdrawn)
- {
- return null;
- }
-
- Log.ConversationTruncated(_logger, transcript.ConversationId, from, transcript.TurnIndex);
-
- gate.Enqueue(() => new ValueTask(
- _store.TruncateAsync(transcript.ConversationId, from, CancellationToken.None).AsTask()));
-
- return withdrawn;
- });
+ return _writes.TruncateFromAsync(session, parentMessageId, cancellationToken);
}
- /// Adds the caller-facing turn of a graph row: what the caller said, and what it heard.
+ ///
+ /// Withdraws an edit that reaches under the summary: the parent is a row the summary stands in for, or the
+ /// edit takes the whole conversation. The store cuts the rows, and the session reads every row back.
+ /// Ask first, and come here when it says no.
+ ///
/// The session this conversation runs on.
- /// What the caller said.
- /// What the caller heard, or when it heard nothing.
- ///
- /// The state to store beside the words, on the same terms as
- /// .
- ///
- /// What the caller calls the message it sent, if it named one.
- ///
- public string? AppendCallerFacingTurn(
- AgentSession session,
- ChatMessage spoken,
- ChatMessage? heard,
- ConversationSessionState? state = null,
- string? firstMessageId = null)
+ /// The message the edit hangs off, or for the whole conversation.
+ /// Cancels the cut.
+ /// The turns the cut withdrew, or when nothing went.
+ public ValueTask CutUnderSummaryAsync(
+ AgentSession session, string? parentMessageId, CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(spoken);
+ ArgumentNullException.ThrowIfNull(session);
- return AppendTurn(session, heard is null ? [spoken] : [spoken, heard], state, firstMessageId);
+ return _writes.CutUnderSummaryAsync(session, parentMessageId, cancellationToken);
}
- ///
- /// Replaces the reply the caller was hearing with the words the caller actually heard.
- ///
- public bool TruncateLastReply(AgentSession session, string heard, TimeSpan played)
+ /// Rewrites the reply of a turn already committed to the text the user was shown. It never appends.
+ /// The session this conversation runs on.
+ /// The turn whose reply is rewritten. It must be the turn of the conversation's last reply.
+ /// The text the user saw or heard.
+ /// when the last reply belongs to another turn, or there is none.
+ public bool RewriteReply(AgentSession session, int turnIndex, string shown)
{
ArgumentNullException.ThrowIfNull(session);
- ArgumentNullException.ThrowIfNull(heard);
+ ArgumentNullException.ThrowIfNull(shown);
- return UnderLock(
- session,
- (transcript, gate) =>
- {
- IReadOnlyList rows = transcript.TruncateLastReply(heard);
- if (rows.Count == 0)
- {
- return false;
- }
-
- Log.ReplyTruncated(_logger, rows[0].ConversationId, rows[0].TurnIndex, played.TotalMilliseconds);
-
- foreach (ConversationMessage row in rows)
- {
- gate.Enqueue(() => _store.RewriteAsync(row.ConversationId, row.MessageId, row.Content, CancellationToken.None));
- }
-
- return true;
- });
+ return _writes.Rewrite(session, turnIndex, shown);
}
///
@@ -339,11 +288,7 @@ public Task DrainAsync(AgentSession session)
{
ArgumentNullException.ThrowIfNull(session);
- ConversationGate gate = GateFor(session);
- lock (gate.Sync)
- {
- return gate.Writes;
- }
+ return _gates.Writes(session);
}
///
@@ -352,35 +297,54 @@ protected override ValueTask> ProvideChatHistoryAsync(
{
ArgumentNullException.ThrowIfNull(context);
- return new(context.Session is { } session ? Read(session) : []);
+ return new(context.Session is { } session && IsBound(session) ? Read(session) : []);
}
- ///
+ ///
+ /// Stages the run's response for the turn. It writes nothing durable: the hook runs before the stage
+ /// machine and the fallback layer, so the words and their state are written together by
+ /// . Staging appends, and never returns
+ /// staged messages (design section 7, item 1).
+ ///
protected override ValueTask StoreChatHistoryAsync(
InvokedContext context, CancellationToken cancellationToken = default)
{
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Session is { } session && IsBound(session) && context.ResponseMessages is { } response)
+ {
+ _ = UnderLock(
+ session,
+ (_, gate) =>
+ {
+ gate.Staged.AddRange(response);
+ return true;
+ });
+ }
+
return default;
}
- /// Runs one piece of work against the conversation's transcript, alone.
- private TResult UnderLock(AgentSession session, Func work)
+ private static bool IsBound(AgentSession session)
{
- ConversationGate gate = GateFor(session);
+ return session.StateBag.TryGetValue(ConversationKey, out string? _);
+ }
- lock (gate.Sync)
- {
- return work(gate.Transcript, gate);
- }
+ // The user row comes from the turn, so the hook keeps only what the caller sent: context providers'
+ // system lines (clock, reminders, skills) arrive in the request too (design probe G1).
+ private static IEnumerable ExternalOnly(IEnumerable messages)
+ {
+ return messages.Where(message => message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
}
- private TResult UnderLock(AgentSession session, Func work)
+ private TResult UnderLock(AgentSession session, Func work)
{
- return UnderLock(session, (transcript, _) => work(transcript));
+ return _gates.Under(session, work);
}
- private ConversationGate GateFor(AgentSession session)
+ private TResult UnderLock(AgentSession session, Func work)
{
- return _gates.GetValue(session, _ => new ConversationGate(_logger));
+ return UnderLock(session, (transcript, _) => work(transcript));
}
}
}
diff --git a/src/AgentCore.Application/Transcript/ConversationGate.cs b/src/AgentCore.Application/Transcript/ConversationGate.cs
index 74b6bd45..edc616e9 100644
--- a/src/AgentCore.Application/Transcript/ConversationGate.cs
+++ b/src/AgentCore.Application/Transcript/ConversationGate.cs
@@ -1,4 +1,7 @@
+using AgentCore.Application.Conversation;
using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Ports;
+using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace AgentCore.Application.Transcript
@@ -8,9 +11,12 @@ namespace AgentCore.Application.Transcript
/// store writes, which never faults and lets the conversation outlive a store that refuses.
///
/// Creates the gate of one conversation.
+ /// Store 1, where every queued write lands.
/// Where a refused write is logged.
- internal sealed class ConversationGate(ILogger logger)
+ internal sealed class ConversationGate(IConversationStore store, ILogger logger)
{
+ private readonly IConversationStore _store = store;
+
private readonly ILogger _logger = logger;
/// Gets the lock every read and every change of this conversation's transcript takes.
@@ -22,24 +28,94 @@ internal sealed class ConversationGate(ILogger logger)
/// Gets the tail of this conversation's store writes. It never faults.
public Task Writes { get; private set; } = Task.CompletedTask;
- /// Gets or sets where a dropped write of this conversation is reported, if anywhere.
- public TranscriptWriteDropped? Dropped { get; set; }
+ /// Gets or sets where a dropped write of this conversation is counted, if anywhere.
+ public ITranscriptLossCounter? Losses { get; set; }
+
+ ///
+ /// Gets what the framework handed the provider for the running turn and nothing has committed yet.
+ /// It grows once per inner run, because a loop: entry and an approval re-entry run the agent
+ /// several times in one turn.
+ ///
+ public List Staged { get; } = [];
+
+ /// Takes everything staged for the running turn, and leaves nothing staged.
+ public List TakeStaged()
+ {
+ List staged = [.. Staged];
+ Staged.Clear();
+ return staged;
+ }
+
+ /// Queues the append of rows the transcript just took, with the state that rides their batch.
+ ///
+ /// Completes once the append landed or was dropped: when the store refused it because the
+ /// conversation already saved the turn it names.
+ ///
+ public Task QueueAppend(IReadOnlyList rows, ConversationSessionState? state)
+ {
+ ConversationMessageDraft[] drafts = [.. rows.Select(row => new ConversationMessageDraft(row.TurnIndex, row.Content, row.MessageId) { CoversUpTo = row.CoversUpTo })];
+ string conversationId = Transcript.ConversationId;
+ return Enqueue(
+ () => new ValueTask(_store.AppendAsync(conversationId, drafts, state, CancellationToken.None).AsTask()),
+ [.. drafts.Select(draft => draft.MessageId)]);
+ }
+
+ /// Queues the rewrite of rows the transcript just changed in place.
+ public void QueueRewrite(IReadOnlyList rows)
+ {
+ foreach (ConversationMessage row in rows)
+ {
+ _ = Enqueue(() => _store.RewriteAsync(row.ConversationId, row.MessageId, row.Content, CancellationToken.None));
+ }
+ }
+
+ /// Queues the delete of rows the transcript just dropped.
+ public void QueueRemove(IReadOnlyList rows)
+ {
+ foreach (ConversationMessage row in rows)
+ {
+ _ = Enqueue(() =>
+ {
+ Losses?.Withdrew([row.MessageId]);
+ return _store.DeleteMessageAsync(row.ConversationId, row.MessageId, CancellationToken.None);
+ });
+ }
+ }
+
+ /// Queues the delete of every row from one ordinal on.
+ /// The first ordinal to delete.
+ /// The ids of the rows the transcript just dropped for it.
+ public void QueueTruncate(int fromOrdinal, IReadOnlyList withdrawn)
+ {
+ string conversationId = Transcript.ConversationId;
+ _ = Enqueue(() =>
+ {
+ Losses?.Withdrew(withdrawn);
+ return new ValueTask(_store.TruncateAsync(conversationId, fromOrdinal, CancellationToken.None).AsTask());
+ });
+ }
/// Queues one store write behind everything this conversation has already queued. Runs under .
/// The write.
- public void Enqueue(Func write)
+ /// The ids of the rows the write appends, or none for a write that changes rows in place.
+ /// Completes once the write landed or was dropped: when the store refused a turn it already saved.
+ public Task Enqueue(Func write, IReadOnlyList? appended = null)
{
- Writes = WriteAfterAsync(Writes, write, Transcript.ConversationId, Transcript.TurnIndex);
+ Task written = WriteAfterAsync(Writes, write, Transcript.ConversationId, Transcript.TurnIndex, appended ?? []);
+ Writes = written;
+ return written;
}
/// Writes to the store, and lets the conversation outlive a store that refuses.
- private async Task WriteAfterAsync(Task previous, Func write, string conversationId, int turnIndex)
+ private async Task WriteAfterAsync(
+ Task previous, Func write, string conversationId, int turnIndex, IReadOnlyList appended)
{
await previous.ConfigureAwait(false);
try
{
await write().ConfigureAwait(false);
+ return false;
}
#pragma warning disable CA1031 // A store 1 write failure never ends a conversation, and never breaks the chain behind it.
catch (Exception exception)
@@ -47,10 +123,8 @@ private async Task WriteAfterAsync(Task previous, Func write, string
{
Log.TranscriptWriteFailed(_logger, conversationId, turnIndex, exception);
- // The words are lost and the conversation is not. The report is what turns that into a fact the
- // host can count; the contract of TranscriptWriteDropped is that it cannot throw, which
- // is what keeps this chain from ever faulting.
- Dropped?.Invoke(turnIndex, exception);
+ Losses?.Dropped(turnIndex, appended, exception);
+ return exception is ConversationTurnConflictException;
}
}
}
diff --git a/src/AgentCore.Application/Transcript/ConversationGates.cs b/src/AgentCore.Application/Transcript/ConversationGates.cs
new file mode 100644
index 00000000..a9a499dc
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/ConversationGates.cs
@@ -0,0 +1,49 @@
+using System.Runtime.CompilerServices;
+using AgentCore.Application.Ports;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Transcript
+{
+ ///
+ /// Finds the gate of each session and runs work against it alone. The provider is one object shared by
+ /// every conversation, so this is what keeps two sessions on two transcripts.
+ ///
+ /// Creates the registry over one backing store.
+ /// Store 1, which every gate writes through to.
+ /// Where a refused write is logged.
+ internal sealed class ConversationGates(IConversationStore store, ILogger logger)
+ {
+ private readonly ConditionalWeakTable _gates = [];
+
+ private readonly IConversationStore _store = store;
+
+ private readonly ILogger _logger = logger;
+
+ /// Runs one piece of work against the session's transcript, under the session's lock.
+ public TResult Under(AgentSession session, Func work)
+ {
+ ConversationGate gate = GateFor(session);
+
+ lock (gate.Sync)
+ {
+ return work(gate.Transcript, gate);
+ }
+ }
+
+ /// Reads the tail of the session's store writes. It never faults.
+ public Task Writes(AgentSession session)
+ {
+ ConversationGate gate = GateFor(session);
+ lock (gate.Sync)
+ {
+ return gate.Writes;
+ }
+ }
+
+ private ConversationGate GateFor(AgentSession session)
+ {
+ return _gates.GetValue(session, _ => new ConversationGate(_store, _logger));
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/ConversationTranscript.cs b/src/AgentCore.Application/Transcript/ConversationTranscript.cs
index 318aa5be..5d0f2743 100644
--- a/src/AgentCore.Application/Transcript/ConversationTranscript.cs
+++ b/src/AgentCore.Application/Transcript/ConversationTranscript.cs
@@ -73,9 +73,6 @@ public void Resync(IReadOnlyList rows, int nextOrdinal)
NextOrdinal = nextOrdinal;
Revision++;
- // Without this a caller who barges in on the first reply of a resumed session is not heard:
- // TruncateLastReply finds no ordinal to cut at and returns nothing, and the provider reports
- // that as a write it declined rather than as a cut it lost.
RestoreLastAssistantOrdinal();
}
@@ -126,7 +123,7 @@ public bool CanTruncateFrom(string? parentMessageId)
int last = int.MinValue;
foreach (StoredMessage stored in Messages)
{
- if (stored.Ordinal < fromOrdinal || stored.CoversUpTo is not null)
+ if (!Withdrawn(stored, fromOrdinal))
{
continue;
}
@@ -140,12 +137,23 @@ public bool CanTruncateFrom(string? parentMessageId)
return null;
}
- _ = Messages.RemoveAll(stored => stored.Ordinal >= fromOrdinal && stored.CoversUpTo is null);
+ _ = Messages.RemoveAll(stored => Withdrawn(stored, fromOrdinal));
RestoreLastAssistantOrdinal();
Revision++;
return new WithdrawnTurns(first, last);
}
+ /// The ids of the rows withdraws from on.
+ public IReadOnlyList IdsFrom(int fromOrdinal)
+ {
+ return [.. Messages.Where(stored => Withdrawn(stored, fromOrdinal)).Select(static stored => stored.MessageId)];
+ }
+
+ private static bool Withdrawn(StoredMessage stored, int fromOrdinal)
+ {
+ return stored.Ordinal >= fromOrdinal && stored.CoversUpTo is null;
+ }
+
private void RestoreLastAssistantOrdinal()
{
LastAssistantOrdinal = Messages
@@ -286,46 +294,61 @@ public IReadOnlyList Append(
}
/// Cuts the turn the caller was hearing down to the words the caller actually heard.
- public IReadOnlyList TruncateLastReply(string heard)
+ /// The words the caller heard, across every step of the turn's reply.
+ /// The turn the last reply must belong to, or for whichever turn it is.
+ ///
+ /// The rows rewritten in place, and the rows removed because the cut left them carrying nothing, by the
+ /// same rule a running turn's cut drops them ().
+ ///
+ public ReplyRewrite RewriteReply(string heard, int? turnIndex = null)
{
ArgumentNullException.ThrowIfNull(heard);
if (LastAssistantOrdinal is not int ordinal
- || Messages.Find(message => message.Ordinal == ordinal) is not { } spoken)
+ || Messages.Find(message => message.Ordinal == ordinal) is not { } spoken
+ || (turnIndex is { } wanted && spoken.TurnIndex != wanted))
{
- return [];
+ return new ReplyRewrite([], []);
}
+ List replies = Messages.FindAll(stored =>
+ stored.TurnIndex == spoken.TurnIndex && stored.Message.Role == ChatRole.Assistant && stored.CoversUpTo is null);
+ List laid = ShownWords.Lay([.. replies.Select(stored => stored.Message)], heard);
+
List rows = [];
- foreach (StoredMessage stored in Messages)
+ List removed = [];
+ for (int index = 0; index < replies.Count; index++)
{
- if (stored.TurnIndex != spoken.TurnIndex || stored.Message.Role != ChatRole.Assistant || stored.CoversUpTo is not null)
+ StoredMessage stored = replies[index];
+ if (ReferenceEquals(laid[index], stored.Message) && stored.Ordinal != ordinal)
{
continue;
}
- bool isReply = stored.Ordinal == ordinal;
- if (!isReply && !stored.Message.Contents.Any(content => content is TextContent))
+ ConversationMessage row = new(ConversationId, stored.Ordinal, stored.TurnIndex, laid[index], stored.MessageId);
+ if (!TurnWords.Carries(laid[index]))
{
+ _ = Messages.Remove(stored);
+ removed.Add(row);
continue;
}
- List kept = [.. stored.Message.Contents.Where(content => content is not TextContent)];
-
- ChatMessage corrected = stored.Message.Clone();
- corrected.Contents = isReply && heard.Length > 0 ? [new TextContent(heard), .. kept] : kept;
- stored.Message = corrected;
+ stored.Message = laid[index];
+ rows.Add(row);
+ }
- rows.Add(new ConversationMessage(
- ConversationId, stored.Ordinal, stored.TurnIndex, corrected, stored.MessageId));
+ ReplyRewrite rewrite = new(rows, removed);
+ if (removed.Count > 0)
+ {
+ RestoreLastAssistantOrdinal();
}
- if (rows.Count > 0)
+ if (rewrite.Changed)
{
Revision++;
}
- return rows;
+ return rewrite;
}
/// Mints a name for a row the caller had no name for.
diff --git a/src/AgentCore.Application/Transcript/ConversationWrites.cs b/src/AgentCore.Application/Transcript/ConversationWrites.cs
new file mode 100644
index 00000000..774b8cd6
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/ConversationWrites.cs
@@ -0,0 +1,191 @@
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Ports;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Transcript
+{
+ ///
+ /// The durable writes of a conversation: each changes the session's transcript under its lock and queues the
+ /// matching store write in the same step, so the store sees the writes in the order the transcript took them.
+ ///
+ /// Creates the writes over the sessions' gates.
+ /// The gate of every session.
+ /// Store 1, which an edit under the summary cuts directly.
+ /// Where cuts and withdrawals are logged.
+ internal sealed class ConversationWrites(ConversationGates gates, IConversationStore store, ILogger logger)
+ {
+ private readonly ConversationGates _gates = gates;
+
+ private readonly IConversationStore _store = store;
+
+ private readonly ILogger _logger = logger;
+
+ /// Appends one sealed turn, taking everything staged for it.
+ ///
+ /// The ids of the user's message and of the last message, which is null when the user's is the only one, the words
+ /// the rows say, and the append's outcome.
+ ///
+ public TurnWrite Commit(AgentSession session, TurnCommit commit)
+ {
+ return _gates.Under(
+ session,
+ (transcript, gate) =>
+ {
+ IReadOnlyList rows = transcript.Append(
+ TurnWords.Compose(commit, gate.TakeStaged()), commit.UserMessageId);
+ Task refused = gate.QueueAppend(rows, commit.State);
+ return new TurnWrite(
+ rows[0].MessageId,
+ rows.Count > 1 ? rows[^1].MessageId : null,
+ TurnWords.Spoken(rows.Select(row => row.Content)),
+ refused);
+ });
+ }
+
+ /// Rewrites the last reply to , when it belongs to .
+ /// when a row was rewritten or removed.
+ public bool Rewrite(AgentSession session, int turnIndex, string text)
+ {
+ return _gates.Under(
+ session,
+ (transcript, gate) =>
+ {
+ ReplyRewrite rewrite = transcript.RewriteReply(text, turnIndex);
+ if (!rewrite.Changed)
+ {
+ return false;
+ }
+
+ gate.QueueRewrite(rewrite.Rewritten);
+ gate.QueueRemove(rewrite.Removed);
+ return true;
+ });
+ }
+
+ /// Withdraws every row after , or every row when it is null.
+ /// The turns withdrawn, or when nothing went.
+ public async ValueTask TruncateFromAsync(AgentSession session, string? parentMessageId, CancellationToken cancellationToken)
+ {
+ ITranscriptLossCounter? losses = _gates.Under(session, static (_, gate) => gate.Losses);
+
+ // Judged before the truncate, which may delete every row of an append the store took and still reported lost.
+ TranscriptLossVerdict? verdict = losses is null
+ ? null
+ : await losses.JudgeAsync(session, cancellationToken).ConfigureAwait(false);
+
+ WithdrawnTurns? withdrawn = _gates.Under(
+ session,
+ (transcript, gate) =>
+ {
+ int from;
+ if (parentMessageId is null)
+ {
+ from = 0;
+ }
+ else if (transcript.OrdinalOf(parentMessageId) is { } parent)
+ {
+ from = parent + 1;
+ }
+ else
+ {
+ return (WithdrawnTurns?)null;
+ }
+
+ IReadOnlyList ids = transcript.IdsFrom(from);
+ if (transcript.TruncateFrom(from) is not { } taken)
+ {
+ return null;
+ }
+
+ Log.ConversationTruncated(_logger, transcript.ConversationId, from, transcript.TurnIndex);
+
+ gate.QueueTruncate(from, ids);
+ return taken;
+ });
+
+ if (withdrawn is not null && verdict is { } judged && losses is not null)
+ {
+ losses.Realigned(judged);
+ }
+
+ return withdrawn;
+ }
+
+ ///
+ /// Withdraws every row after in the store itself, once every queued write
+ /// has landed, and reads the rows back into the session: the summary stands in for rows the session no longer holds.
+ ///
+ /// The turns withdrawn, or when the store holds no message of that name.
+ public async ValueTask CutUnderSummaryAsync(
+ AgentSession session, string? parentMessageId, CancellationToken cancellationToken)
+ {
+ await _gates.Writes(session).ConfigureAwait(false);
+
+ (string conversationId, int turnIndex, ITranscriptLossCounter? losses) = _gates.Under(
+ session, static (transcript, gate) => (transcript.ConversationId, transcript.TurnIndex, gate.Losses));
+
+ int from;
+ if (parentMessageId is null)
+ {
+ from = 0;
+ }
+ else if (await _store.OrdinalOfAsync(conversationId, parentMessageId, cancellationToken).ConfigureAwait(false) is { } parent)
+ {
+ from = parent + 1;
+ }
+ else
+ {
+ return null;
+ }
+
+ // Judged before the cut, which may delete rows the store took and still reported lost.
+ TranscriptLossVerdict? verdict = losses is null
+ ? null
+ : await losses.JudgeAsync(session, cancellationToken).ConfigureAwait(false);
+
+ ConversationCut cut = await _store.TruncateAsync(conversationId, from, cancellationToken).ConfigureAwait(false);
+ Log.ConversationTruncated(_logger, conversationId, from, turnIndex);
+
+ ConversationRecord refreshed = await _store.GetAsync(conversationId, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidOperationException(
+ $"Store 0 holds no conversation '{conversationId}' for its own session to edit.");
+ IReadOnlyList rows = await _store.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false);
+
+ _ = _gates.Under(
+ session,
+ (transcript, _) =>
+ {
+ transcript.Resync(rows, refreshed.NextOrdinal);
+ return true;
+ });
+
+ if (verdict is { } judged && losses is not null)
+ {
+ losses.Realigned(judged);
+ }
+
+ return cut.Turns;
+ }
+
+ /// Puts one summary in place of every row at or below , unless the words moved since .
+ /// when the summary now stands.
+ public bool Compact(AgentSession session, ChatMessage summary, int coversUpTo, int revision)
+ {
+ return _gates.Under(
+ session,
+ (transcript, gate) =>
+ {
+ if (transcript.Compact(summary, coversUpTo, revision) is not { } row)
+ {
+ return false;
+ }
+
+ _ = gate.QueueAppend([row], state: null);
+ return true;
+ });
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs
new file mode 100644
index 00000000..b8e48f42
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs
@@ -0,0 +1,43 @@
+using Microsoft.Agents.AI;
+
+namespace AgentCore.Application.Transcript
+{
+ ///
+ /// Counts the store writes of one session that the store did not take, so a later read of the store can tell
+ /// them from another session's writes.
+ ///
+ internal interface ITranscriptLossCounter
+ {
+ /// Reports one dropped store 1 write, and counts it lost. Never throws.
+ /// The turn the transcript was on when the write was queued.
+ ///
+ /// The ids of the rows the write would have appended, or none for a write that changes rows in place. The store
+ /// may still hold them: a write can land and still fail to say so.
+ ///
+ /// Why the store did not take the write.
+ void Dropped(int turnIndex, IReadOnlyList appended, Exception exception);
+
+ ///
+ /// Records that the session is deleting rows itself. A lost append that landed no longer holds them, and is judged
+ /// by the rows it still holds. Called in the write queue, after every write queued before the delete. Never throws.
+ ///
+ /// The ids of the rows the session deletes.
+ void Withdrew(IReadOnlyList messageIds);
+
+ ///
+ /// Judges the losses counted so far against what the store holds, before an edit cuts rows the judgement reads, or
+ /// realigns the session's words and ordinals with the store. After that, the store no longer says which of the
+ /// session's writes it lost.
+ ///
+ /// The session of this conversation.
+ /// Cancels the reads.
+ /// The verdict, or when nothing was lost or the store cannot be read.
+ ValueTask JudgeAsync(AgentSession session, CancellationToken cancellationToken);
+
+ ///
+ /// Records that the edit was judged for is done: the verdict now stands for every loss it judged.
+ ///
+ /// What returned.
+ void Realigned(TranscriptLossVerdict verdict);
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/ReplyRewrite.cs b/src/AgentCore.Application/Transcript/ReplyRewrite.cs
new file mode 100644
index 00000000..6ac50248
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/ReplyRewrite.cs
@@ -0,0 +1,11 @@
+namespace AgentCore.Application.Transcript
+{
+ /// What one rewrite of a committed reply did to its rows.
+ /// The rows whose content changed in place.
+ /// The rows the cut left carrying nothing, which the conversation no longer holds.
+ internal sealed record ReplyRewrite(IReadOnlyList Rewritten, IReadOnlyList Removed)
+ {
+ /// Gets whether the rewrite changed any row.
+ public bool Changed => Rewritten.Count > 0 || Removed.Count > 0;
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/ShownWords.cs b/src/AgentCore.Application/Transcript/ShownWords.cs
new file mode 100644
index 00000000..a8ae8453
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/ShownWords.cs
@@ -0,0 +1,102 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Transcript
+{
+ /// Lays the text a user was shown back over the messages of the reply that produced it.
+ internal static class ShownWords
+ {
+ /// Cuts the text of down to , in order.
+ /// The messages of one reply, oldest first.
+ /// Every word of the reply the user saw or heard, across all its steps.
+ ///
+ /// One message per input, in the same order: the same instance when its text stands, a copy when its text
+ /// was cut. Each message keeps its text while still starts with it; the first that
+ /// does not, or the last message with text, takes the rest, and every later message loses its text. When no
+ /// message has text, the shown text is added as one assistant message at the end. Trimmed at both ends.
+ ///
+ public static List Lay(IList messages, string shown)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ ArgumentNullException.ThrowIfNull(shown);
+
+ int last = -1;
+ for (int index = 0; index < messages.Count; index++)
+ {
+ if (HasText(messages[index]))
+ {
+ last = index;
+ }
+ }
+
+ List laid = new(messages.Count + 1);
+ string rest = shown;
+ for (int index = 0; index < messages.Count; index++)
+ {
+ ChatMessage message = messages[index];
+ if (!HasText(message))
+ {
+ laid.Add(message);
+ continue;
+ }
+
+ string text = message.Text;
+ if (index < last && rest.StartsWith(text, StringComparison.Ordinal))
+ {
+ laid.Add(message);
+ rest = rest[text.Length..];
+ continue;
+ }
+
+ laid.Add(WithText(message, rest));
+ rest = string.Empty;
+ }
+
+ if (last < 0 && shown.Length > 0)
+ {
+ laid.Add(new ChatMessage(ChatRole.Assistant, shown));
+ }
+
+ TrimEnds(laid);
+ return laid;
+ }
+
+ private static void TrimEnds(List laid)
+ {
+ int first = laid.FindIndex(HasText);
+ if (first >= 0)
+ {
+ laid[first] = WithText(laid[first], laid[first].Text.TrimStart());
+ }
+
+ int last = laid.FindLastIndex(HasText);
+ if (last >= 0)
+ {
+ laid[last] = WithText(laid[last], laid[last].Text.TrimEnd());
+ }
+ }
+
+ private static bool HasText(ChatMessage message)
+ {
+ return message.Contents.Any(content => content is TextContent { Text.Length: > 0 });
+ }
+
+ private static ChatMessage WithText(ChatMessage message, string text)
+ {
+ if (message.Text == text)
+ {
+ return message;
+ }
+
+ int at = message.Contents.ToList().FindIndex(content => content is TextContent);
+ List contents = [.. message.Contents.Where(content => content is not TextContent)];
+ if (text.Length > 0)
+ {
+ contents.Insert(Math.Max(at, 0), new TextContent(text));
+ }
+
+ ChatMessage cut = message.Clone();
+ cut.Contents = contents;
+ return cut;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs
new file mode 100644
index 00000000..90380ff3
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs
@@ -0,0 +1,24 @@
+using AgentCore.Application.Conversation;
+
+namespace AgentCore.Application.Transcript
+{
+ /// What store 1 held beyond a session's words, read without changing them.
+ /// The transcript's revision when the read began. The rows apply only while it stands.
+ /// The rows store 1 read for the session.
+ /// The next free ordinal of the conversation, from store 0's own counter.
+ /// The state store 0 holds, or when it holds none.
+ internal sealed record TranscriptCatchUp(int Revision, IReadOnlyList Rows, int NextOrdinal, ConversationSessionState? State)
+ {
+ /// Gets the index the conversation's next turn takes, from the state store 0 holds.
+ public int NextTurnIndex => State?.NextTurnIndex ?? 0;
+
+ /// Gets the last of the session's lost writes counted before the read. A catch-up that takes these rows clears it and every one before it.
+ public int LossMark { get; init; }
+
+ ///
+ /// Gets whether the store lost a write of this session and holds another session's writes in its place. The
+ /// session's own state then no longer follows from what the store holds.
+ ///
+ public bool Overtaken { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TranscriptLoss.cs b/src/AgentCore.Application/Transcript/TranscriptLoss.cs
new file mode 100644
index 00000000..9e468f05
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TranscriptLoss.cs
@@ -0,0 +1,19 @@
+namespace AgentCore.Application.Transcript
+{
+ /// One store 1 write of a session that the store did not take, or did not say it took.
+ /// Where the loss falls among every loss the session counted, from 1.
+ /// The ids of the rows the write appends, or none for a write that changes rows in place.
+ /// Whether the store refused the write because another session saved its turn first.
+ ///
+ /// Whether this marks a judgement, not a write: when an edit cut the rows the losses before this one were judged by,
+ /// the store held another session's writes in their place.
+ ///
+ internal sealed record TranscriptLoss(int Sequence, IReadOnlyList Appended, bool Refused, bool Overtaken = false)
+ {
+ ///
+ /// Gets the ids of the session has not deleted itself since. Had the append landed, the
+ /// store would hold exactly these of its rows.
+ ///
+ public IReadOnlyList Held { get; init; } = Appended;
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs b/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs
new file mode 100644
index 00000000..1d0caee4
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Transcript
+{
+ /// What a session's lost writes came to, judged against one read of the store.
+ /// The last loss counted before the read. Later losses are not judged.
+ /// Whether the store held another session's writes in place of the losses.
+ internal readonly record struct TranscriptLossVerdict(int Mark, bool Overtaken);
+}
diff --git a/src/AgentCore.Application/Transcript/TranscriptLosses.cs b/src/AgentCore.Application/Transcript/TranscriptLosses.cs
new file mode 100644
index 00000000..72f391af
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TranscriptLosses.cs
@@ -0,0 +1,112 @@
+namespace AgentCore.Application.Transcript
+{
+ ///
+ /// The store 1 writes of one session that the store did not take, since the session last caught up with it.
+ ///
+ ///
+ /// A catch-up clears the losses counted up to the mark it read before its store read, not every loss: a write the
+ /// store drops while a catch-up is in flight stays counted for the next one.
+ ///
+ internal sealed class TranscriptLosses
+ {
+ private readonly Lock _gate = new();
+
+ // Both guarded by _gate.
+ private readonly List _lost = [];
+
+ private int _counted;
+
+ /// Gets whether a write was lost since the session last caught up.
+ internal bool Any
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _lost.Count > 0;
+ }
+ }
+ }
+
+ /// Gets whether the store refused a turn since the session last caught up.
+ internal bool Refused
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _lost.Exists(static loss => loss.Refused);
+ }
+ }
+ }
+
+ /// Counts one write the store did not take.
+ /// The ids of the rows it would have appended.
+ /// Whether the store refused it because another session saved its turn first.
+ internal void Lose(IReadOnlyList appended, bool refused)
+ {
+ lock (_gate)
+ {
+ _lost.Add(new TranscriptLoss(++_counted, appended, refused));
+ }
+ }
+
+ /// Records that the session deleted rows itself, so no loss counts them missing.
+ /// The ids of the rows deleted.
+ internal void Withdrew(IReadOnlyList messageIds)
+ {
+ lock (_gate)
+ {
+ for (int index = 0; index < _lost.Count; index++)
+ {
+ TranscriptLoss loss = _lost[index];
+ if (loss.Held.Any(messageIds.Contains))
+ {
+ _lost[index] = loss with { Held = [.. loss.Held.Where(id => !messageIds.Contains(id))] };
+ }
+ }
+ }
+ }
+
+ /// Reads the losses as they stand.
+ ///
+ /// The mark to hand once the session took what the store held, and every loss since the
+ /// last catch-up, oldest first.
+ ///
+ internal (int Mark, IReadOnlyList Since) Read()
+ {
+ lock (_gate)
+ {
+ return (_counted, [.. _lost]);
+ }
+ }
+
+ /// Records that the session took what the store held when was read.
+ /// What returned before that store read.
+ internal void CaughtUp(int mark)
+ {
+ lock (_gate)
+ {
+ _ = _lost.RemoveAll(loss => loss.Sequence <= mark);
+ }
+ }
+
+ ///
+ /// Records that an edit judged by is done: it cut rows the losses up to the verdict's
+ /// mark were judged by, or realigned the session's words and ordinals with the store. The verdict stands for those
+ /// losses from then on. A refusal and an overtaking stay counted: only a catch-up that takes the stored state
+ /// clears them.
+ ///
+ internal void Realigned(TranscriptLossVerdict verdict)
+ {
+ lock (_gate)
+ {
+ _ = _lost.RemoveAll(loss => loss.Sequence <= verdict.Mark && !loss.Refused && !loss.Overtaken);
+ if (verdict.Overtaken)
+ {
+ _lost.Add(new TranscriptLoss(++_counted, [], Refused: false, Overtaken: true));
+ }
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TranscriptPosition.cs b/src/AgentCore.Application/Transcript/TranscriptPosition.cs
new file mode 100644
index 00000000..1cb5022b
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TranscriptPosition.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Transcript
+{
+ /// Where a session's words stood at one moment, read under the conversation's lock.
+ /// Completes when every store 1 write queued up to that moment has landed.
+ /// The transcript's revision, which every later change of the words moves.
+ /// The next ordinal as the session counted it.
+ internal readonly record struct TranscriptPosition(Task Written, int Revision, int NextOrdinal);
+}
diff --git a/src/AgentCore.Application/Transcript/TurnCommit.cs b/src/AgentCore.Application/Transcript/TurnCommit.cs
new file mode 100644
index 00000000..52185736
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TurnCommit.cs
@@ -0,0 +1,46 @@
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Runtime;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Transcript
+{
+ /// One turn as it is sealed: the one durable append of that turn.
+ /// The message the user sent. It is always written first.
+ /// Design: docs/handoff/2026-09-22-maf-native-engine-design.md, section 2 "Types" and section 3.
+ internal sealed record TurnCommit(ChatMessage User)
+ {
+ /// Gets what the run yielded as the caller saw it, or when it yielded nothing.
+ public AgentResponse? Seen { get; init; }
+
+ /// Gets what the turn layers reported about the run, or when no layer marked it.
+ public TurnDisposition? Disposition { get; init; }
+
+ /// Gets the cut that stopped the turn, or when nothing cut it.
+ public TurnCut? Cut { get; init; }
+
+ ///
+ /// Gets whether the run answered in full: nothing cut it and no fallback replaced its reply. A turn that did
+ /// not keeps only its finished tool pairs and .
+ ///
+ public bool Completed { get; init; } = true;
+
+ ///
+ /// Gets whether the conversation keeps only what the user said and was shown, as a graph row does: its
+ /// nodes' tool pairs and lines to each other are neither.
+ ///
+ public bool CallerFacing { get; init; }
+
+ ///
+ /// Gets the line the user was shown, when the turn is caller-facing or did not complete, or
+ /// to read it from and .
+ ///
+ public string? Reply { get; init; }
+
+ /// Gets the state that rides the same batch as the words, or for none.
+ public ConversationSessionState? State { get; init; }
+
+ /// Gets what the caller calls , or to name it in the append.
+ public string? UserMessageId { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TurnWords.cs b/src/AgentCore.Application/Transcript/TurnWords.cs
new file mode 100644
index 00000000..290b3fc8
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TurnWords.cs
@@ -0,0 +1,92 @@
+using AgentCore.Application.Runtime;
+using AgentCore.Application.Runtime.Turn;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Transcript
+{
+ /// Decides the messages one sealed turn writes, by the Cut rule of design section 3.
+ internal static class TurnWords
+ {
+ /// Builds the messages of one turn, the user's first.
+ /// The turn being sealed.
+ /// Every message the framework staged for the turn, oldest first.
+ ///
+ /// For a caller-facing turn: the line the user was shown, when there is one. For a cut turn: every step's
+ /// words and finished tool pairs in the order they ran, the words cut to what was shown (see
+ /// ). For a turn that failed without a cut: every step's shown words and finished tool
+ /// pairs in the order they ran, then the fallback line as its own message, written once. Otherwise everything
+ /// staged, or what the caller saw when nothing was staged.
+ ///
+ public static List Compose(TurnCommit commit, List staged)
+ {
+ List words = [commit.User];
+
+ // A cancelled or faulted run stages nothing (probes P03, P06), so what the caller saw is the fuller record.
+ IList source = commit.Seen?.Messages ?? staged;
+
+ if (commit.CallerFacing)
+ {
+ string heard = commit.Reply ?? commit.Cut?.ShownText ?? ReplyText.From(source);
+ if (heard.Length > 0)
+ {
+ words.Add(new ChatMessage(ChatRole.Assistant, heard));
+ }
+
+ return words;
+ }
+
+ if (commit.Cut is null && commit.Completed)
+ {
+ words.AddRange(staged.Count > 0 ? staged : source);
+ return words;
+ }
+
+ List finished = TurnMessages.FinishedMessages(source);
+ string all = string.Concat(finished.Select(message => message.Text));
+
+ if (commit.Cut is { } cut)
+ {
+ words.AddRange(ShownWords.Lay(finished, cut.ShownText ?? all).Where(Carries));
+ return words;
+ }
+
+ // The fallback layer yields its line on the same stream, so the words seen end with it; it is laid
+ // off the steps and written once, as its own message.
+ string fallback = (commit.Reply ?? ReplyText.From(source)).Trim();
+ string seen = all.TrimEnd();
+ string before = fallback.Length > 0 && seen.EndsWith(fallback, StringComparison.Ordinal)
+ ? seen[..^fallback.Length]
+ : all;
+
+ words.AddRange(ShownWords.Lay(finished, before).Where(Carries));
+ if (fallback.Length > 0)
+ {
+ words.Add(new ChatMessage(ChatRole.Assistant, fallback));
+ }
+
+ return words;
+ }
+
+ ///
+ /// Reads the words a turn's messages say, as the store's verify query rebuilds them from the rows: the text parts
+ /// of every assistant message, in order, with no separator. The turn's replyTextSha256 and
+ /// utteranceUntilInterruptSha256 hash this, so a hash and the rows it proves cannot drift apart.
+ ///
+ /// The messages built for the turn, or the rows written from them.
+ /// The words, or an empty string when no assistant message carries text.
+ public static string Spoken(IEnumerable messages)
+ {
+ return string.Concat(messages
+ .Where(message => message.Role == ChatRole.Assistant)
+ .SelectMany(message => message.Contents.OfType())
+ .Select(text => text.Text));
+ }
+
+ /// Whether a message still says or does anything once its words are cut.
+ internal static bool Carries(ChatMessage message)
+ {
+ return message.Contents.Any(content => content
+ is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent or TextContent { Text.Length: > 0 });
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Transcript/TurnWrite.cs b/src/AgentCore.Application/Transcript/TurnWrite.cs
new file mode 100644
index 00000000..70d90ce5
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/TurnWrite.cs
@@ -0,0 +1,16 @@
+namespace AgentCore.Application.Transcript
+{
+ /// One turn's commit: the ids its append wrote, and whether the store kept them.
+ /// What the user's message was written under.
+ /// What the last message was written under, or when the user's message is all the turn wrote.
+ /// The words the append's rows say, as reads them.
+ ///
+ /// Completes once the append landed or was dropped: when the store refused it because the
+ /// conversation already saved this turn.
+ ///
+ internal sealed record TurnWrite(string UserMessageId, string? ReplyMessageId, string Spoken, Task Refused)
+ {
+ /// Gets the two ids together.
+ public (string UserMessageId, string? ReplyMessageId) Ids => (UserMessageId, ReplyMessageId);
+ }
+}
diff --git a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
index 7072d7e7..a6981477 100644
--- a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
+++ b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
@@ -32,4 +32,13 @@
+
+
+
+
+
+
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs b/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs
deleted file mode 100644
index 6ad3138f..00000000
--- a/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace AgentCore.AspNetCore.Conversation
-{
- /// Settings for .
- public sealed class ConversationSweepOptions
- {
- /// How long a conversation is kept after its last activity, before the sweep erases it.
- public TimeSpan Retention { get; set; } = TimeSpan.FromDays(90);
-
- /// How often the sweep runs.
- public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
-
- /// How many conversations one sweep pass erases at most.
- public int BatchSize { get; set; } = 500;
- }
-}
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs
index c287bcd0..29a974a5 100644
--- a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs
+++ b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs
@@ -1,3 +1,4 @@
+using AgentCore.AspNetCore.DependencyInjection;
using AgentCore.Application.Ports;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -7,34 +8,38 @@
namespace AgentCore.AspNetCore.Conversation
{
///
- /// Runs the retention sweep of for as long as the host is up.
+ /// Runs the retention sweep of response ids for as long as the host is up.
///
- ///
- /// AgentCore never registers this on its own: a host that wants old conversations erased opts in by
- /// calling AddConversationSweep. Without that call, conversations are kept forever.
- ///
public sealed class ConversationSweeper(
IServiceProvider services,
- IOptions options,
+ IOptions options,
TimeProvider timeProvider,
ILogger logger) : BackgroundService
{
+ private static readonly TimeSpan Interval = TimeSpan.FromHours(1);
+
+ private const int BatchSize = 500;
+
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
- ConversationSweepOptions settings = options.Value;
+ TimeSpan? retention = options.Value.ResponseRetention;
+
+ if (retention is null)
+ {
+ return;
+ }
- // Hosted service is constructed before AgentCore's own boot has not finished.
IConversations conversations = services.GetRequiredService();
- using PeriodicTimer timer = new(settings.Interval, timeProvider);
+ using PeriodicTimer timer = new(Interval, timeProvider);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
int swept = await conversations
- .SweepAsync(settings.Retention, settings.BatchSize, stoppingToken)
+ .SweepAsync(retention.Value, BatchSize, stoppingToken)
.ConfigureAwait(false);
ConversationSweeperLog.Swept(logger, swept);
@@ -52,11 +57,11 @@ internal static partial class ConversationSweeperLog
{
/// One sweep pass finished.
/// The logger of the sweeper.
- /// How many conversations the pass erased.
+ /// How many response ids the pass deleted.
[LoggerMessage(
EventId = 1,
Level = LogLevel.Debug,
- Message = "the retention sweep erased {Count} conversation(s).")]
+ Message = "the retention sweep deleted {Count} response id(s).")]
public static partial void Swept(ILogger logger, int count);
/// A sweep pass could not finish.
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs b/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs
deleted file mode 100644
index 19247f47..00000000
--- a/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs
+++ /dev/null
@@ -1,423 +0,0 @@
-using AgentCore.Application.Ports;
-using Microsoft.Extensions.AI;
-
-namespace AgentCore.AspNetCore.Conversation
-{
- ///
- /// Decides which turn of one conversation may speak, and which one a barge-in cuts short.
- ///
- ///
- ///
- /// The first of the two barge-in gates lives here: once a turn is marked interrupted this class
- /// stops handing its fragments to . The last gate, immediately
- /// before the wire, belongs to the port's own implementation and is expressed in that
- /// implementation's private vocabulary, so no turn id of this class ever crosses the port.
- ///
- ///
- /// Builds the arbiter one conversation runs its turns through.
- ///
- /// Where a turn's reply goes, one fragment at a time.
- /// Watches a turn's task, and never lets its fault go unobserved.
- ///
- /// Logs that one prompt was held until the turn in flight ends, given the id of the conversation it was
- /// held for. Called once per held prompt, from inside the turn lock.
- ///
- ///
- /// Logs that a further prompt was dropped because one was already held, given the id of the conversation
- /// it was dropped for. Called at most once per connection, from inside the turn lock.
- ///
- ///
- /// Cancelled once, for any reason the transport is going away. Every turn reads it, and no
- /// held prompt starts a turn after it fires.
- ///
- internal sealed class ConversationTurnArbiter(
- IConversationPort initialSession,
- IConversationOutputPort output,
- ConnectionTaskObserver observer,
- Action logPromptHeld,
- Action logPendingPromptDropped,
- CancellationToken connectionToken)
- {
- // Not readonly, and volatile for the same reason the connection's own session field is: a
- // repeated setup frame replaces the conversation this arbiter runs turns against, and every turn that
- // starts after it must run against the replacement. One arbiter serves the whole connection,
- // across as many sessions as the vendor names, because "one turn at a time" is a property of
- // the transport and not of any one session: a second arbiter would carry its own _turnActive,
- // its own held prompt, and its own ids, and a prompt arriving while the replaced session's turn
- // still ran would start a second turn writing into the same channel.
- private volatile IConversationPort _session = initialSession;
- private readonly IConversationOutputPort _output = output;
- private readonly ConnectionTaskObserver _observer = observer;
- private readonly Action _logPromptHeld = logPromptHeld;
- private readonly Action _logPendingPromptDropped = logPendingPromptDropped;
- private readonly CancellationToken _connectionToken = connectionToken;
-
- private Task _turn = Task.CompletedTask;
- private bool _loggedPendingPromptDropped;
-
- // Guards _turnId, _interruptedTurnId, _turnActive, _pendingPrompt, and every place _turn is
- // read then reassigned. The read loop reaches this lock through StartTurnAsync and
- // Interrupt, one frame at a time; a turn's own task reaches it too, from
- // RunPendingPrompt, once it finishes. _turnId and _interruptedTurnId are also read without the
- // lock, through Interlocked.Read, from inside a turn's own hot loop, where taking a lock on
- // every update would slow the path this guard exists to protect.
- private readonly Lock _turnLock = new();
- private long _turnId;
-
- // The id a barge-in invalidated, or 0 when nothing has been interrupted yet. This is a
- // separate field from _turnId, not a reuse of it, because _turnId also advances on every
- // ordinary turn transition — RunPendingPrompt mints the next id as soon as one turn's own task
- // finishes, which can race ahead of the write loop still sending that turn's own trailing
- // frames. A single shared "current id" field cannot tell "a newer turn has started normally"
- // apart from "this turn was cut short": both look like a mismatch to a check that only asks
- // "does this item's id equal the current one." Recording which id was interrupted, instead of
- // just raising a shared counter, is what lets a turn's own guard ask the question that
- // actually matters: was *this* turn, specifically, the one barge-in cut off, regardless of how
- // many ordinary turns have started since.
- private long _interruptedTurnId;
-
- // The newest turn whose output this arbiter has actually handed to the output port, or 0 while
- // no turn has produced a word. It is not _turnId, and the difference is the whole of the
- // barge-in target: the transport paces the audio itself, so turn N is still being spoken after
- // its own stream ended, and RunPendingPrompt starts turn N+1 inside turn N's own finally. A
- // barge-in in that window belongs to turn N — the turn the caller was hearing — and marking
- // N+1 instead would drop every update of a turn nobody has heard yet and skip its closing
- // last: true frame, leaving the caller with dead air and the vendor with an unfinished reply.
- private long _spokenTurnId;
- private bool _turnActive;
- private string? _pendingPrompt;
-
- /// What a voice caller hears when a tool waits on an approval the conversation cannot take.
- internal const string PendingApprovalNotice =
- "That action needs a human approval, which this conversation can't take.";
-
- /// Gets the turn running now, or the one that ran last.
- ///
- /// Read under the same lock every writer takes. can reassign the
- /// turn from a turn's own task, off the read loop, so teardown is not the only writer's own
- /// reader: without the lock it could observe a stale reference and let the real last turn
- /// finish unobserved. itself checks the connection token before
- /// it will start a turn from a held prompt, which is what stops the reverse case — a fresh turn
- /// appearing here after teardown already read it.
- ///
- public Task CurrentTurn
- {
- get
- {
- lock (_turnLock)
- {
- return _turn;
- }
- }
- }
-
- /// Points this arbiter at another conversation, keeping every turn it is already running.
- /// The conversation every turn started from now on runs against.
- ///
- /// For the one caller that needs it: a transport whose vendor names the conversation a second time on
- /// one connection. Only the session moves. _turnActive, the held prompt, the ids, and the
- /// log-once flag all stay exactly where they are, because "one turn at a time" and "one held
- /// prompt, logged once" are promises this connection makes for its whole life and not for one
- /// session of it. A turn already in flight keeps the session it started on, the same way it
- /// would have before this method existed.
- ///
- public void Rebind(IConversationPort session)
- {
- _session = session;
- }
-
- /// Starts a turn for what the caller just said, or holds it until the turn in flight ends.
- /// What the caller said.
- ///
- /// The turn this conversation started, or a completed task when the prompt was held or dropped. It
- /// reports only completion: a turn's own fault stays on , which the
- /// next turn and teardown both already observe through .
- ///
- public Task StartTurnAsync(string text)
- {
- // Read once, here, so the turn this conversation starts and the line it may log about holding the
- // prompt both name the conversation the setup frame most recently gave this connection.
- IConversationPort session = _session;
-
- Task justFinished;
- lock (_turnLock)
- {
- if (_turnActive)
- {
- // The caller finished a second sentence before the agent spoke. There is no
- // interrupt frame for this, because the vendor never truncated anything, so there
- // is no heard text to record. IConversationPort throws on a second turn of one
- // conversation, and a throw here would drop it, so one prompt is held and runs once the
- // turn in flight ends. Item 6a of section 11 draws the line at one: a queue of
- // held prompts would answer questions the caller has already moved past, so a
- // third prompt is dropped instead of queued behind the first.
- //
- // _turnActive, not _turn.IsCompleted: RunPendingPrompt runs inside RunTurnAsync's
- // own finally, strictly before that turn's Task itself transitions to completed, so
- // a check against IsCompleted here could see "no turn running" for a conversation whose own
- // RunPendingPrompt had already run and found nothing pending — losing this prompt
- // outright, since nothing would ever come back to run it. _turnActive is cleared by
- // RunPendingPrompt in that same lock acquisition instead, so this check and that
- // clear can never miss each other.
- if (_pendingPrompt is null)
- {
- _pendingPrompt = text;
- _logPromptHeld(session.ConversationId);
- }
- else if (!_loggedPendingPromptDropped)
- {
- // Log once for the conversation, not once for the frame. Same rule as the
- // unknown-frame line.
- _loggedPendingPromptDropped = true;
- _logPendingPromptDropped(session.ConversationId);
- }
-
- return Task.CompletedTask;
- }
-
- justFinished = _turn;
- _turnActive = true;
- }
-
- return StartAfterAsync(session, text, justFinished);
- }
-
- /// Ends the running turn where the caller cut the reply off, and stops the audio behind it.
- /// The text the caller actually heard, as the transport reported it.
- /// How much of the reply played, as the transport reported it.
- ///
- /// when the conversation recorded the barge-in, against the running turn or
- /// against the turn that finished last, and when there was nothing to
- /// record it against.
- ///
- ///
- /// Synchronous, and it stays synchronous. It marks the interrupted id, decides
- /// cutsRunningTurn, and calls before anything
- /// awaits, and it hands 's task to the observer rather
- /// than awaiting it: this runs on the read loop, and a read loop that blocks cannot receive the
- /// next frame.
- ///
- public bool Interrupt(string heardText, TimeSpan playedDuration)
- {
- // Read once, here, so the record goes to the conversation the setup frame most recently named — the
- // same field, read at the same point, as when this method still lived on the connection.
- IConversationPort session = _session;
-
- bool cutsRunningTurn;
- lock (_turnLock)
- {
- // Mark before cancelling. Every project that solves this race does it in this order:
- // a token already past the guard in RunTurnAsync must find its own id already marked
- // interrupted right here, not a cancellation that is still on its way to the model
- // conversation.
- //
- // _spokenTurnId is what is marked, and never _turnId. The two differ exactly when a
- // held prompt fired: turn N finishes streaming, RunPendingPrompt starts turn N+1 inside
- // turn N's own finally, and the transport is still speaking turn N. _turnId then names
- // N+1, a turn the caller has not heard one word of, and marking it would silence it and
- // skip its closing frame. _spokenTurnId names the turn whose output actually reached
- // the transport, which is the turn the caller was hearing and the only one a barge-in
- // may cut.
- //
- // Zero means no turn has produced a word yet, so there is nothing to cut short and the
- // mark stays where it was.
- long spoken = Interlocked.Read(ref _spokenTurnId);
- if (spoken != 0)
- {
- _ = Interlocked.Exchange(ref _interruptedTurnId, spoken);
- }
-
- // The same comparison the mark above rests on, carried across the seam. ConversationSession
- // cannot reach it: it decides from whether the *running* turn is audible to it, and a
- // turn is audible to it the moment it hands over any content at all — a tool call, a
- // line of reasoning — none of which is a word the transport ever spoke. So turn N+1 can
- // be audible to the core while _spokenTurnId still names turn N, which is the turn the
- // transport is still playing and the only one the caller has heard. Deciding here, where
- // both ids are known and already under the lock that guards them, is what puts the heard
- // text on turn N instead of on a turn nobody has heard one word of.
- //
- // Zero answers false as well. Nothing of this conversation has reached the transport, so there
- // is no running turn the caller was hearing, and cutting one short would destroy a reply
- // against nothing — the same rule the mark above already follows by staying put.
- cutsRunningTurn = spoken != 0 && spoken == Interlocked.Read(ref _turnId);
- }
-
- // The transport measured both values itself. D28 and item 6a: nothing here estimates either
- // one. A false result means there was no turn to record the barge-in against — not merely
- // that none was running, because a turn that already ended is amended rather than ignored.
- bool recorded = session.Interrupt(heardText, playedDuration, cutsRunningTurn);
-
- // The transport already stopped its own audio, and anything the port still holds is the
- // only audio left that could start it playing again. Handed to the observer rather than
- // awaited: this runs on the read loop, and a read loop that blocks cannot receive the next
- // frame. StopAsync itself is called here, before the discard, so whatever it does
- // synchronously — dropping what has not been spoken — has already happened by the time this
- // method returns.
- _ = _observer.ObserveAsync(_output.StopAsync(_connectionToken).AsTask(), ConnectionTaskKind.WriteLoop);
-
- return recorded;
- }
-
- /// Observes the turn that just ended, then starts the next one and follows it to its end.
- /// The conversation the turn this starts runs against.
- /// What the caller said.
- /// The turn this conversation read out of .
- /// A task that completes when the turn this conversation starts completes.
- private async Task StartAfterAsync(IConversationPort session, string text, Task justFinished)
- {
- // A conversation runs many turns, and CurrentTurn only ever holds the most recent one. Observing the
- // finished turn here is what stops a turn that faulted mid-conversation from being silently
- // replaced by the next one before anything looks at it. The in-flight guard above only
- // proved _turnActive was false, not that justFinished itself has completed: RunPendingPrompt
- // clears _turnActive from inside RunTurnAsync's own finally, strictly before that turn's
- // Task transitions to completed, so a moment can exist where _turnActive already reads false
- // while justFinished is still finishing the last step of its own async state machine. This
- // await can therefore still yield here, but only for as long as that tail takes to unwind —
- // microseconds — which is why it is safe to await inline rather than fire-and-forget. Only
- // the very last turn of the conversation still relies on teardown's own observation.
- await _observer.ObserveAsync(justFinished, ConnectionTaskKind.Turn).ConfigureAwait(false);
-
- Task started;
- lock (_turnLock)
- {
- // Teardown may have completed while the previous turn unwound; a turn started now
- // would outlive the connection's own wait.
- if (_connectionToken.IsCancellationRequested)
- {
- _turnActive = false;
- return;
- }
-
- long turnId = Interlocked.Increment(ref _turnId);
- started = _turn = RunTurnAsync(session, text, turnId);
- }
-
- try
- {
- await started.ConfigureAwait(false);
- }
- catch (Exception)
- {
- // Swallowed here on purpose, and only here. The task this method returns exists so a
- // caller can follow the turn to its end; the fault itself stays on CurrentTurn, which
- // the next turn's own observation above and teardown's both already report through
- // ConnectionTaskObserver. Reporting it a second time here would log one fault twice.
- }
- }
-
- /// Runs one turn end to end, and streams its reply to the output port.
- ///
- /// The conversation this turn runs against. Passed in rather than read off the field, so a turn already
- /// in flight when names another conversation still finishes on the one it started on.
- ///
- /// What the caller said.
- /// The id this turn's output is marked with.
- /// A task that completes when the turn has ended, however it ended.
- private async Task RunTurnAsync(IConversationPort session, string text, long turnId)
- {
- try
- {
- await foreach (ChatResponseUpdate? update in session
- .RunTurnStreamingAsync(text, _connectionToken)
- .ConfigureAwait(false))
- {
- // This check narrows the race; it does not close it. It sits after the await on the
- // enumerator and before the await on the output port, so it stops every update the
- // model produces *after* Interrupt marks this turn interrupted. It cannot stop
- // an update already past this line at that moment — already queued inside the port,
- // or parked inside SpeakAsync waiting for room — so the port's own last gate,
- // immediately before the wire, is what actually keeps a straggler off the wire.
- // That gate reads the port's own reply generation rather than this id, because no
- // turn id of this class may cross the port; StopAsync raises the generation, and
- // every fragment already queued under the old one is dropped there. The check is
- // against _interruptedTurnId, not _turnId: a later, ordinary turn starting also
- // moves _turnId, and that alone must not silence a turn nothing actually
- // interrupted.
- //
- // continue, not return: abandoning the enumeration here would dispose
- // ConversationSession's own iterator from the outside, which unwinds it past the code that
- // records the interruption on LastTurn rather than through it. Session.Interrupt
- // already asked the model call to stop; this loop still has to drain whatever
- // update that call already had in flight so ConversationSession reaches its own ending on
- // its own terms. Every update after the raised id is read and thrown away instead
- // of queued, which is what keeps the queue itself from growing on a barge-in.
- if (Interlocked.Read(ref _interruptedTurnId) == turnId)
- {
- continue;
- }
-
- if (update.Text is { Length: > 0 } piece)
- {
- // Marked before the write, not after it. Once a piece is committed to the
- // port the caller is going to hear it, and a barge-in that lands while this
- // write waits on backpressure still belongs to this turn. The ids only ever
- // increase here, because RunPendingPrompt starts the next turn from inside this
- // turn's own finally, so a plain exchange cannot move this value backwards.
- _ = Interlocked.Exchange(ref _spokenTurnId, turnId);
-
- await _output.SpeakAsync(piece, _connectionToken).ConfigureAwait(false);
- }
- }
-
- if (Interlocked.Read(ref _interruptedTurnId) != turnId)
- {
- if (session.LastTurn?.Approvals is { Count: > 0 })
- {
- // A tool waits on a human answer, and a voice conversation has no surface to give one.
- // Say so instead of hanging in the silence the pending turn speaks.
- await _output.SpeakAsync(PendingApprovalNotice, _connectionToken).ConfigureAwait(false);
- }
-
- // The vendor closes a reply on last: true, and the sample uses an empty final
- // token when the stream ended with no trailing text. A barge-in already ended
- // this turn, so a closing frame now would speak a reply the caller cut off.
- await _output.CompleteAsync(_connectionToken).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException)
- {
- // ConversationSession absorbs a barge-in's own cancellation internally and ends the turn
- // normally, so what reaches here is the connection's own teardown token instead. The
- // conversation itself goes on; only this turn stops.
- }
- finally
- {
- RunPendingPrompt();
- }
- }
-
- /// Starts the one prompt held while this turn ran.
- ///
- /// Called from a turn's own finally, so it runs on whichever thread that turn happens
- /// to finish on — never the read loop, and strictly before the Task
- /// returns actually completes. Reading , clearing or handing it to a new turn, and reassigning all
- /// happen inside one lock acquisition, because a caller of that
- /// reads _turnActive as false must also see as null in that
- /// same instant — otherwise both this method and that caller could start a turn at once.
- ///
- private void RunPendingPrompt()
- {
- lock (_turnLock)
- {
- // The connection is tearing down, so a fresh turn here would speak after the transport
- // stopped reading, and teardown's own read of CurrentTurn (also under this lock) may
- // already have captured the task this conversation is inside of — leaving a turn nothing waits
- // for. The caller's last sentence goes unanswered either way once the socket is gone.
- if (_pendingPrompt is not { } held || _connectionToken.IsCancellationRequested)
- {
- _turnActive = false;
- return;
- }
-
- _pendingPrompt = null;
- long turnId = Interlocked.Increment(ref _turnId);
-
- // Read here and not captured when the prompt was held: a setup frame that named another
- // conversation in between makes that call the one to answer, which is the rule the connection
- // followed when this method still lived there.
- _turn = RunTurnAsync(_session, held, turnId);
- }
- }
- }
-}
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
index 4bb87029..fea7b305 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
@@ -12,6 +12,7 @@
using AgentCore.Application.Tools.Builtin;
using AgentCore.Application.Tools.Registry;
using AgentCore.AspNetCore.DependencyInjection.Startup;
+using AgentCore.AspNetCore.Voice;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -26,9 +27,11 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable
private readonly AgentCoreOptions _options;
private readonly ILoggerFactory _loggers;
+
private readonly List _opened = [];
private readonly Lock _gate = new();
+
private int _closed;
/// Takes the options a host filled and the loggers the container holds.
@@ -78,7 +81,7 @@ public AgentCoreBoot(IOptions options, ILoggerFactory loggers,
/// Gets the queue that answers the audit port, not the store behind it.
internal QueuedAuditSink AuditQueue => Started.AuditQueue;
- /// Gets one factory, one agent, and one session store per entry.
+ /// Gets one factory and one agent per entry, over the one session owner shared by every entry.
internal EntryRegistry Entries => Started.Entries;
/// Gets the container, read for host-registered seams the boot honors.
@@ -208,6 +211,7 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken)
.ConfigureAwait(false);
ConfigurationValidator.ValidateToolReferences(configuration, tools.ServedIds);
+ FillerKeyCheck.Warn(configuration, tools.ServedIds, bootLogger);
SkillCatalog? skills = await SkillsStartup
.OpenAsync(_options, _loggers, cancellationToken)
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs
index 72330457..93b4b347 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs
@@ -4,6 +4,7 @@
using AgentCore.Application.Ports;
using AgentCore.Application.Tools;
using AgentCore.Application.Tools.Binding;
+using AgentCore.AspNetCore.Voice;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
@@ -39,6 +40,9 @@ public sealed class AgentCoreOptions
///
public HybridCache? Cache { get; set; }
+ /// Gets or sets how long a response id is kept before the retention sweep deletes it.
+ public TimeSpan? ResponseRetention { get; set; } = TimeSpan.FromDays(30);
+
/// Gets the map from a binds: name to the host delegate behind it.
public ToolBindingRegistry Bindings { get; } = new();
@@ -102,18 +106,18 @@ public sealed class AgentCoreOptions
/// Gets the observers the host registered, in the order it registered them.
internal IReadOnlyList Observers => _observers;
- /// Gets the store opener the host bound, or for the in-memory default.
- internal Func? ConversationSessions { get; private set; }
+ /// Gets the owner opener the host bound, or for the in-memory default.
+ internal Func, IConversationSessions>? ConversationSessions { get; private set; }
- /// Binds the session store, one per entry.
+ /// Binds the one session owner for the whole app, in place of the in-memory default.
///
- /// Opens the store for one entry. It takes the entry name and the factory that builds that
- /// entry's sessions, and it runs once per entry the document declares. Each conversation must return a
- /// distinct store: two entries that share one store let a vendor call id arriving on both
- /// entries read one call through two shapes.
+ /// Opens the owner. It takes every entry's session factory, keyed by entry name, and runs once for the
+ /// whole app. The owner's dictionary is keyed by conversation id alone, so one id is one conversation
+ /// across every entry: while one entry holds it live, an open from another entry is refused.
///
/// These options, so a host chains its conversations.
- public AgentCoreOptions UseConversationSessions(Func open)
+ public AgentCoreOptions UseConversationSessions(
+ Func, IConversationSessions> open)
{
ArgumentNullException.ThrowIfNull(open);
ConversationSessions = open;
@@ -335,7 +339,7 @@ public AgentCoreOptions AddToolSource(Func toolSo
///
/// Binds the folder under which every conversation gets its own workspace directory,
- /// <root>/<conversationId>/, created with the conversation and deleted when the conversation ends.
+ /// <root>/<conversationId>/, created with the conversation's session and deleted when the session unloads or the conversation ends.
///
public AgentCoreOptions UseWorkspace(string root)
{
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs
index 956c4802..c493794d 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs
@@ -11,8 +11,6 @@
using Microsoft.AspNetCore.WebSockets;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace AgentCore.AspNetCore.DependencyInjection
@@ -35,6 +33,11 @@ public static IServiceCollection AddAgentCore(
_ = services.AddOptions();
_ = services.Configure(configure);
+ _ = services.AddOptions()
+ .Validate(
+ static o => o.ResponseRetention is null || o.ResponseRetention >= TimeSpan.Zero,
+ "AgentCoreOptions.ResponseRetention must be null or zero or greater.")
+ .ValidateOnStart();
_ = services.AddLogging();
_ = services.AddSingleton();
@@ -62,6 +65,7 @@ public static IServiceCollection AddAgentCore(
_ = services.AddSingleton(provider => provider.GetRequiredService());
+ _ = services.AddHostedService();
_ = services.AddSingleton(Boot(boot => boot.Telemetry!));
_ = services.AddSingleton(Boot(boot => boot.Knowledge!));
@@ -77,12 +81,6 @@ public static IServiceCollection AddAgentCore(
provider.GetRequiredService>().Value.Cache
?? PassThroughHybridCache.Instance);
- _ = services.AddHostedService(provider => new ConversationSessionSweeper(
- provider,
- provider.GetRequiredService(),
- provider.GetService()?.CreateLogger()
- ?? NullLogger.Instance));
-
services.TryAddSingleton(provider => new ChatConversationTitler(
provider.GetRequiredService(),
provider.GetRequiredService()
@@ -99,30 +97,6 @@ public static IServiceCollection AddAgentCore(
return services;
}
- ///
- /// Opts this host into the periodic retention sweep of stored conversations.
- ///
- /// The service collection of the host.
- /// Sets the retention, the interval, and the batch size. Defaults live on .
- /// The same collection, so a host chains its conversations.
- public static IServiceCollection AddConversationSweep(
- this IServiceCollection services,
- Action? configure = null)
- {
- ArgumentNullException.ThrowIfNull(services);
-
- OptionsBuilder builder = services.AddOptions();
-
- if (configure is not null)
- {
- _ = builder.Configure(configure);
- }
-
- _ = services.AddHostedService();
-
- return services;
- }
-
/// Registers WebSocket options that suit a phone conversation rather than a browser tab.
/// The service collection of the host.
/// The same collection, so a host chains its conversations.
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs
index 3cdd393d..63ca2f3a 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs
@@ -1,9 +1,7 @@
-using AgentCore.Application.Conversation;
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Ports;
using AgentCore.Application.Providers;
-using AgentCore.AspNetCore.Conversation;
+using AgentCore.AspNetCore.Voice;
using Microsoft.AspNetCore.Http;
namespace AgentCore.AspNetCore.DependencyInjection.Startup
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs
index 9930bb8b..4950ca7f 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs
@@ -10,7 +10,7 @@
namespace AgentCore.AspNetCore.DependencyInjection.Startup
{
/// Everything the conversation seam opened: the audit chain, and one session factory per entry.
- /// One factory, one agent, and one session store per entry, keyed by entry name.
+ /// One factory and one agent per entry, and the one session owner shared by all of them.
/// The queue that answers the audit port, not the store behind it.
internal readonly record struct ConversationSessionSeam(
EntryRegistry Entries, QueuedAuditSink Queue);
@@ -18,7 +18,7 @@ internal readonly record struct ConversationSessionSeam(
/// The seam a conversation arrives on: the audit queue, the observers, and one session factory per entry.
internal static class ConversationSessionStartup
{
- /// Opens the audit store the document names and builds one session factory per entry over it.
+ /// Opens the audit store the document names, and builds the one session owner over every entry's factory.
/// The owner the audit chain is tracked against.
/// The loaded document. It carries providers.audit.
/// The options the host filled. It carries the audit vendors, the clock, and any observer.
@@ -47,7 +47,12 @@ internal static async ValueTask OpenAsync(
StartupLog.AuditSinkDefaulted(loggers.CreateLogger());
}
- QueuedAuditSink auditSink = boot.Track(new QueuedAuditSink(store, loggers.CreateLogger()));
+ TimeProvider timeProvider = options.TimeProvider ?? TimeProvider.System;
+
+ QueuedAuditSink auditSink = boot.Track(new QueuedAuditSink(
+ store,
+ loggers.CreateLogger(),
+ timeProvider: timeProvider));
ILogger sessionLogger = loggers.CreateLogger();
@@ -68,15 +73,12 @@ internal static async ValueTask OpenAsync(
}
IReadOnlyList observers = ConversationObservers.Standard(auditSink, sessionLogger, options.Observers);
- TimeProvider timeProvider = options.TimeProvider ?? TimeProvider.System;
Dictionary factories = new(StringComparer.Ordinal);
- Dictionary agents = new(StringComparer.Ordinal);
- Dictionary sessions = new(StringComparer.Ordinal);
foreach ((string? entryName, Application.Configuration.Compilation.CompiledAgent? compiled) in graph.Entries)
{
- ConversationSessionFactory factory = new(
+ factories[entryName] = new ConversationSessionFactory(
compiled,
graph.Guards,
ConversationSessionFactory.CreateExtractor(compiled, graph.ChatClients),
@@ -84,15 +86,40 @@ internal static async ValueTask OpenAsync(
sessionLogger,
observers,
options.WorkspaceRoot);
+ }
+
+ IConversationSessions sessions = options.ConversationSessions?.Invoke(factories)
+ ?? boot.Track(BuiltinSessions(factories, timeProvider, options.WorkspaceRoot, sessionLogger));
- factories[entryName] = factory;
- agents[entryName] = new AgentCoreAgent(factory, entryName);
- sessions[entryName] = options.ConversationSessions?.Invoke(entryName, factory)
- ?? new InMemoryConversationSessions(
- factory, InMemoryConversationSessions.DefaultIdleTimeout, timeProvider);
+ Dictionary agents = new(StringComparer.Ordinal);
+ foreach (string entryName in factories.Keys)
+ {
+ agents[entryName] = new AgentCoreAgent(sessions, entryName);
}
return new ConversationSessionSeam(new EntryRegistry(factories, agents, sessions), auditSink);
}
+
+ ///
+ /// Builds the default session owner, and — before anything can open a session through it — sweeps the
+ /// workspace root of every folder a crashed process left behind. The sweep runs here, once per boot, in
+ /// this one owner, and only for the built-in owner: a host that replaces it with its own implementation
+ /// through UseConversationSessions takes over its own workspace cleanup too.
+ ///
+ private static InMemoryConversationSessions BuiltinSessions(
+ IReadOnlyDictionary factories,
+ TimeProvider timeProvider,
+ string? workspaceRoot,
+ ILogger logger)
+ {
+ InMemoryConversationSessions sessions = new(factories, InMemoryConversationSessions.DefaultIdleTimeout, timeProvider);
+
+ if (workspaceRoot is not null)
+ {
+ sessions.SweepWorkspaceRoot(workspaceRoot, logger);
+ }
+
+ return sessions;
+ }
}
}
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs
index 2e1869af..f1867629 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs
@@ -3,33 +3,34 @@
namespace AgentCore.AspNetCore.DependencyInjection.Startup
{
- /// One factory, one agent shim, and one session store per entry.
+ /// One factory and one agent shim per entry, over the one session owner shared by every entry.
///
- /// Each entry compiles its own shape over the shared pool, so each entry gets its own
- /// session factory and its own store: a vendor call id arriving on two routes opens two
- /// isolated conversations rather than one conversation read through two shapes. The audit chain, the
- /// observers, and the workspace root are shared across entries, because they describe the
- /// deployment rather than the shape. Agents are named by entry key.
+ /// Each entry compiles its own shape over the shared pool, so each entry gets its own session factory.
+ /// The session owner is shared, because it is the one dictionary, keyed by conversation id, that knows
+ /// which entry holds a conversation live: a vendor call id arriving on a second route is refused while
+ /// the first route still holds it. The audit chain, the observers, and the workspace root are shared
+ /// across entries too, because they describe the deployment rather than the shape. Agents are named by
+ /// entry key.
///
internal sealed class EntryRegistry : IConversationSessionRegistry
{
- /// Creates the registry over the per-entry seams.
+ /// Creates the registry over the per-entry seams and the one session owner.
/// The session factories, keyed by entry name.
/// The agent shims, keyed by entry name.
- /// The session stores, keyed by entry name.
+ /// The one session owner, shared by every entry.
public EntryRegistry(
IReadOnlyDictionary factories,
IReadOnlyDictionary agents,
- IReadOnlyDictionary conversationSessions)
+ IConversationSessions sessions)
{
ArgumentNullException.ThrowIfNull(factories);
ArgumentNullException.ThrowIfNull(agents);
- ArgumentNullException.ThrowIfNull(conversationSessions);
+ ArgumentNullException.ThrowIfNull(sessions);
Factories = factories;
Agents = agents;
- ConversationSessions = conversationSessions;
- Entries = [.. conversationSessions.Keys];
+ Sessions = sessions;
+ Entries = [.. factories.Keys];
}
/// Gets the session factories, keyed by entry name.
@@ -38,8 +39,8 @@ public EntryRegistry(
/// Gets the agent shims, keyed by entry name.
public IReadOnlyDictionary Agents { get; }
- /// Gets the session stores, keyed by entry name.
- public IReadOnlyDictionary ConversationSessions { get; }
+ ///
+ public IConversationSessions Sessions { get; }
///
public IReadOnlyCollection Entries { get; }
@@ -62,12 +63,6 @@ public AgentCoreAgent ForAgent(string entry)
return For(Agents, entry);
}
- ///
- public IConversationSessions ForSessions(string entry)
- {
- return For(ConversationSessions, entry);
- }
-
/// Builds the failure text for an entry nothing declares, in one place.
/// The entry key a route named.
/// The declared entry names.
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs
new file mode 100644
index 00000000..8eb30ff0
--- /dev/null
+++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs
@@ -0,0 +1,40 @@
+using AgentCore.Application.Configuration.Parsing;
+using AgentCore.Application.Configuration.Schema;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.AspNetCore.DependencyInjection.Startup
+{
+ ///
+ /// Warns about a providers.conversation.filler key that names no served tool.
+ /// A warning and not a refusal: a provider can add a tool of that name while a turn runs.
+ ///
+ internal static class FillerKeyCheck
+ {
+ /// Logs one warning for each filler key does not hold.
+ /// The loaded document.
+ /// Every tool id the registry serves, declared and MCP-discovered alike.
+ /// Where the warnings go.
+ internal static void Warn(AgentCoreConfiguration configuration, IReadOnlySet servedToolIds, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ ArgumentNullException.ThrowIfNull(servedToolIds);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (configuration.Providers?.Conversation?.Filler is not { Count: > 0 } filler)
+ {
+ return;
+ }
+
+ foreach (string toolId in filler.Keys.Order(StringComparer.Ordinal))
+ {
+ if (!servedToolIds.Contains(toolId))
+ {
+ StartupLog.FillerToolUnknown(
+ logger,
+ ConfigurationError.AppendPointer("/providers/conversation/filler", toolId),
+ toolId);
+ }
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs b/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs
index ce960bb5..50cc2655 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs
@@ -27,5 +27,18 @@ internal static partial class StartupLog
+ "and no retention window applies to it. Name a durable providers.conversations.kind, or write "
+ "kind: memory to say this was meant.")]
public static partial void ConversationStoreDefaulted(ILogger logger);
+
+ /// A providers.conversation.filler key names no tool the registry serves.
+ /// The boot's own logger.
+ /// The JSON Pointer to the filler key.
+ /// The key, as the document wrote it.
+ [LoggerMessage(
+ EventId = 4,
+ Level = LogLevel.Warning,
+ Message = "{Pointer}: the filler key '{ToolId}' names no tool the registry serves, so its filler never "
+ + "fires unless a tool of that name is added at run time. The shell, files, memory, skills, and "
+ + "search providers name their tools only while a turn runs, so this check cannot see them. Check "
+ + "the key for a typo.")]
+ public static partial void FillerToolUnknown(ILogger logger, string pointer, string toolId);
}
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
index 8630522d..ebdc40a5 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
@@ -233,4 +233,14 @@ internal sealed record FilePayload
/// Gets the link to fetch it from, or when the store has no web door.
public string? Url { get; init; }
}
+
+ /// The ids one turn's commit wrote, as the browser reads it.
+ internal sealed record MessageCommittedPayload
+ {
+ /// Gets what the user's message was written under.
+ public required string UserMessageId { get; init; }
+
+ /// Gets what the last message was written under, or when the user's is the only one.
+ public string? ReplyMessageId { get; init; }
+ }
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs
index 7bf03494..64444821 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using AgentCore.Application.Conversation;
using AgentCore.Application.Runtime;
using AgentCore.Application.Runtime.Harness;
using AgentCore.AspNetCore.DependencyInjection;
@@ -102,7 +103,7 @@ await ResponsesRequestReader.WriteErrorAsync(
AgentCoreAgentSessionStore sessions = http.RequestServices.GetRequiredService();
- (AgentSession Session, string? ConversationId)? opened = await OpenSessionAsync(http, agent, sessions, run, approving: approval is not null, cancellationToken)
+ (AgentSession Session, string ConversationId)? opened = await OpenSessionAsync(http, agent, sessions, run, approving: approval is not null, cancellationToken)
.ConfigureAwait(false);
if (opened is null)
@@ -110,18 +111,9 @@ await ResponsesRequestReader.WriteErrorAsync(
return;
}
- (AgentSession? session, string? conversationId) = opened.Value;
+ (AgentSession session, string conversationId) = opened.Value;
- if (session.GetService() is not { } conversation)
- {
- throw new InvalidOperationException(
- "The session is not one this agent created, so it names no call to run.");
- }
-
- ChatMessage? input = await ReadInputAsync(http, conversation, approval, run.Messages, cancellationToken)
- .ConfigureAwait(false);
-
- if (input is null)
+ if (!await CheckInputAsync(http, approval, run.Messages, cancellationToken).ConfigureAwait(false))
{
return;
}
@@ -129,17 +121,16 @@ await ResponsesRequestReader.WriteErrorAsync(
// A conversation the request named stays the conversation; a keyless first turn minted
// one alongside the session above. A response id is minted every turn either way, so a
// chain can start from any answer.
- ResponsesTurn turn = new(
+ ResponsesPendingTurn pending = new(
agent,
sessions,
session,
- conversation,
- input,
+ approval is null ? ResponsesRequestReader.LastUserMessage(run.Messages) : null,
+ approval,
ResponsesAgentCore.OriginOf(agentcore),
- OpenAIResponses.CreateResponseId(),
conversationId);
- await RunTurnAsync(http, body, turn, dialect: agentcore is not null, cancellationToken).ConfigureAwait(false);
+ await RunTurnAsync(http, body, pending, dialect: agentcore is not null, cancellationToken).ConfigureAwait(false);
}
/// Opens the session the turn runs on: a new one, or the one the continuation names.
@@ -147,7 +138,7 @@ await ResponsesRequestReader.WriteErrorAsync(
/// The session and the conversation id it runs under, or once a
/// refusal has been answered.
///
- private static async Task<(AgentSession Session, string? ConversationId)?> OpenSessionAsync(
+ private static async Task<(AgentSession Session, string ConversationId)?> OpenSessionAsync(
HttpContext http,
AgentCoreAgent agent,
AgentCoreAgentSessionStore sessions,
@@ -158,8 +149,10 @@ await ResponsesRequestReader.WriteErrorAsync(
string? key = run.ContinuationKey;
string? conversationId = run.ConversationId;
- bool known = key is not null
- && await sessions.ContainsAsync(key, cancellationToken).ConfigureAwait(false);
+ string? resolved = key is null
+ ? null
+ : await sessions.ResolveConversationIdAsync(key, cancellationToken).ConfigureAwait(false);
+ bool known = resolved is not null;
bool namesNewConversation = key is not null
&& !known
@@ -167,54 +160,57 @@ await ResponsesRequestReader.WriteErrorAsync(
&& conversationId is not null
&& !approving;
- if (key is null)
+ try
{
- if (approving)
+ if (key is null)
+ {
+ if (approving)
+ {
+ await ResponsesRequestReader.WriteErrorAsync(
+ http,
+ StatusCodes.Status400BadRequest,
+ "an approval answer names the conversation it resumes. Send it with the conversation "
+ + "or previous response id the asking turn answered with.",
+ "missing_session",
+ cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+
+ conversationId = ResponsesRequestReader.NewConversationId();
+ return (await agent.CreateSessionAsync(conversationId, cancellationToken).ConfigureAwait(false), conversationId);
+ }
+
+ if (namesNewConversation)
+ {
+ return (await agent.CreateSessionAsync(key, cancellationToken).ConfigureAwait(false), conversationId!);
+ }
+
+ if (!known)
{
await ResponsesRequestReader.WriteErrorAsync(
http,
- StatusCodes.Status400BadRequest,
- "an approval answer names the conversation it resumes. Send it with the conversation "
- + "or previous response id the asking turn answered with.",
- "missing_session",
+ StatusCodes.Status404NotFound,
+ $"no conversation opens under '{key}'. Send the request with neither a conversation nor a "
+ + "previous response id to start one.",
+ "continuation_not_found",
cancellationToken).ConfigureAwait(false);
return null;
}
- conversationId = ResponsesRequestReader.NewConversationId();
- return (await agent.CreateSessionAsync(conversationId).ConfigureAwait(false), conversationId);
- }
-
- if (namesNewConversation)
- {
- // Unknown conversation with words: start the conversation under that id, so the conversation
- // and the continuation key are one id. Unknown response ids stay 404 below: they name
- // a turn, not a conversation.
- return (await agent.CreateSessionAsync(key).ConfigureAwait(false), conversationId);
+ return (await sessions.GetSessionForConversationAsync(agent, resolved!, cancellationToken).ConfigureAwait(false), resolved!);
}
-
- if (!known)
+ catch (ConversationInUseException failure)
{
- // Either an unknown response id — a turn no answer ever carried — or an unknown
- // conversation beside an approval-only body the parse refused. A conversation with words
- // reaches the namesNewConversation branch; an approval never starts a conversation.
await ResponsesRequestReader.WriteErrorAsync(
http,
- StatusCodes.Status404NotFound,
- $"no conversation opens under '{key}'. Send the request with neither a conversation nor a "
- + "previous response id to start one.",
- "continuation_not_found",
+ StatusCodes.Status409Conflict,
+ failure.Message,
+ "conversation_in_use",
cancellationToken).ConfigureAwait(false);
return null;
}
-
- try
- {
- return (await sessions.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false), conversationId);
- }
catch (InvalidOperationException failure)
{
- // A key minted by another entry is unknown to this one, never a cross-entry read.
await ResponsesRequestReader.WriteErrorAsync(
http,
StatusCodes.Status404NotFound,
@@ -225,32 +221,26 @@ await ResponsesRequestReader.WriteErrorAsync(
}
}
- /// Picks what the turn runs on: the approval answer, or the last user message with words.
- /// The input, or once a refusal has been answered.
- private static async Task ReadInputAsync(
+ /// Checks the request carries one thing to run on: user words, or an approval answer without words.
+ /// once a refusal has been answered.
+ private static async Task CheckInputAsync(
HttpContext http,
- ConversationSession conversation,
ResponsesApprovalAnswer? approval,
IReadOnlyList messages,
CancellationToken cancellationToken)
{
- if (approval is null)
+ if (approval is null && ResponsesRequestReader.LastUserMessage(messages) is null)
{
- if (ResponsesRequestReader.LastUserMessage(messages) is { } user)
- {
- return user;
- }
-
await ResponsesRequestReader.WriteErrorAsync(
http,
StatusCodes.Status400BadRequest,
"the request carries no user message with text, so there is no turn to run.",
"no_user_message",
cancellationToken).ConfigureAwait(false);
- return null;
+ return false;
}
- if (ResponsesRequestReader.LastUserText(messages) is { Length: > 0 })
+ if (approval is not null && ResponsesRequestReader.LastUserText(messages) is { Length: > 0 })
{
await ResponsesRequestReader.WriteErrorAsync(
http,
@@ -259,9 +249,23 @@ await ResponsesRequestReader.WriteErrorAsync(
+ "either words or an answer, never both.",
"mixed_turn",
cancellationToken).ConfigureAwait(false);
- return null;
+ return false;
}
+ return true;
+ }
+
+ ///
+ /// Reads the approval answer off the conversation's queue, once the request holds the conversation, so no other
+ /// turn changes the queue between this read and the turn.
+ ///
+ /// The answer, or once a refusal has been answered.
+ private static async Task ReadApprovalAsync(
+ HttpContext http,
+ ConversationSession conversation,
+ ResponsesApprovalAnswer approval,
+ CancellationToken cancellationToken)
+ {
// Async: a resumed conversation has run no turn in this process, so its ledger session —
// and the queue the suspending turn filed — is still closed. This opens it first.
if (await conversation.TryCreateApprovalAnswerAsync(approval.RequestId, approval.Approved, cancellationToken).ConfigureAwait(false) is { } answer)
@@ -279,39 +283,87 @@ await ResponsesRequestReader.WriteErrorAsync(
return null;
}
- /// Runs the turn on the branch the body asked for, and answers a refused turn as 409.
+ ///
+ /// Runs the turn on the branch the body asked for, once the conversation is free, and answers a refused turn as
+ /// 409.
+ ///
private static async Task RunTurnAsync(
HttpContext http,
JsonElement body,
- ResponsesTurn turn,
+ ResponsesPendingTurn pending,
bool dialect,
CancellationToken cancellationToken)
{
bool streaming = body.TryGetProperty("stream", out JsonElement streamFlag)
&& streamFlag.ValueKind == JsonValueKind.True;
- if (CallerTimeZone.Parse(http.Request.Headers[TimeZoneHeaderName]) is { } zone)
- {
- CallerTimeZone.Set(turn.Session, zone);
- }
-
+ string refusedReason = TurnRefusals.Busy;
try
{
- if (streaming)
+ // The conversation stays busy until the session is filed, after the abort too: a message sent at once,
+ // to this host or another, then waits for this turn's words instead of racing them to the same turn index.
+ ConversationSession conversation = await pending.Agent
+ .EnterRequestAsync(pending.Session, cancellationToken).ConfigureAwait(false);
+ refusedReason = TurnRefusals.Conflict;
+ try
{
- // The dialect is opt-in by the member only our clients send: an OpenAI SDK
- // never carries agentcore, so its stream stays the framework's pure shapes.
- await ResponsesTurnStream.WriteAsync(http, turn, dialect, cancellationToken).ConfigureAwait(false);
+ ChatMessage? input = pending.Approval is { } approval
+ ? await ReadApprovalAsync(http, conversation, approval, cancellationToken).ConfigureAwait(false)
+ : pending.Words;
+
+ if (input is null)
+ {
+ return;
+ }
+
+ if (CallerTimeZone.Parse(http.Request.Headers[TimeZoneHeaderName]) is { } zone)
+ {
+ CallerTimeZone.Set(pending.Session, zone);
+ }
+
+ ResponsesTurn turn = new(
+ pending.Agent,
+ pending.Sessions,
+ pending.Session,
+ conversation,
+ input,
+ pending.Origin,
+ OpenAIResponses.CreateResponseId(),
+ pending.ConversationId);
+
+ if (streaming)
+ {
+ // The dialect is opt-in by the member only our clients send: an OpenAI SDK
+ // never carries agentcore, so its stream stays the framework's pure shapes.
+ await ResponsesTurnStream.WriteAsync(http, turn, dialect, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await ResponsesTurnReply.WriteAsync(http, turn, cancellationToken).ConfigureAwait(false);
+ }
}
- else
+ finally
{
- await ResponsesTurnReply.WriteAsync(http, turn, cancellationToken).ConfigureAwait(false);
+ await conversation.Busy.ExitRequestAsync().ConfigureAwait(false);
}
}
+ catch (ConversationTurnConflictException) when (!http.Response.HasStarted)
+ {
+ await ResponsesRequestReader.WriteErrorAsync(
+ http,
+ StatusCodes.Status409Conflict,
+ ResponsesTurnConflict.MessageFor(refusedReason),
+ ResponsesTurnConflict.Code,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (ConversationTurnConflictException)
+ {
+ // The stream already ended with its error event, or the client that could read one is gone.
+ }
catch (InvalidOperationException exception)
{
- // The turn loop refuses a turn on a finished conversation, and refuses a second turn while one
- // runs. Both are a caller mistake and neither is a defect of this host.
+ // The turn loop refuses a turn on a finished conversation, or on one disposed under it. Neither is a
+ // defect of this host.
await ResponsesRequestReader.WriteErrorAsync(
http,
StatusCodes.Status409Conflict,
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs
new file mode 100644
index 00000000..85e8f893
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs
@@ -0,0 +1,24 @@
+using AgentCore.Application.Runtime;
+using AgentCore.AspNetCore.Sessions;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ /// What a Responses request asks to run, before it holds the conversation.
+ /// The entry the URL named.
+ /// The store the session is filed in after the turn.
+ /// The session the turn advances.
+ /// The user words the turn runs on, or for an approval answer.
+ /// The approval answer the turn runs on, or for words.
+ /// Where the request said the turn came from, or .
+ /// The id of the conversation the turn runs on.
+ internal sealed record ResponsesPendingTurn(
+ AgentCoreAgent Agent,
+ AgentCoreAgentSessionStore Sessions,
+ AgentSession Session,
+ ChatMessage? Words,
+ ResponsesApprovalAnswer? Approval,
+ ConversationTurnOrigin? Origin,
+ string ConversationId);
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs
index 522329e6..cbfd3d31 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs
@@ -1,38 +1,22 @@
using AgentCore.AspNetCore.Sessions;
-using Microsoft.Agents.AI;
namespace AgentCore.AspNetCore.Endpoints
{
- /// Files a session the Responses path just advanced, under every id that names it.
+ /// Files a response id the Responses path just minted, so it later resolves to its conversation.
internal static class ResponsesSessionFiling
{
- ///
- /// Files the session the turn just advanced, under every id that names it now. The response id
- /// always gets the frozen, full envelope; the conversation id gets a pointer at it, so the two
- /// never hold two copies of the same state.
- ///
- /// The store to file the envelope in.
- /// The agent the session belongs to.
- /// The session the turn advanced.
+ /// Records that one response id continues one conversation.
+ /// The store to file the response id in.
/// The id minted for this turn's answer.
- /// The client-facing conversation id, or when none names it.
- /// The internal conversation the continuation rows belong to, always present.
+ /// The conversation the response id continues.
/// Cancels the write.
internal static async Task SaveAsync(
AgentCoreAgentSessionStore sessions,
- AIAgent agent,
- AgentSession session,
string responseId,
- string? conversationId,
string ownerConversationId,
CancellationToken cancellationToken)
{
- await sessions.SaveSessionForConversationAsync(agent, responseId, ownerConversationId, session, cancellationToken).ConfigureAwait(false);
-
- if (conversationId is not null)
- {
- await sessions.SavePointerAsync(conversationId, ownerConversationId, responseId, cancellationToken).ConfigureAwait(false);
- }
+ await sessions.SaveContinuationAsync(responseId, ownerConversationId, cancellationToken).ConfigureAwait(false);
}
}
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs
index 4d8e8735..d404c13f 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs
@@ -5,15 +5,15 @@
namespace AgentCore.AspNetCore.Endpoints
{
- /// One turn the Responses path is about to run, and every id the answer files under.
+ /// One turn the Responses path is about to run, and the response id the answer files under.
/// The entry the URL named.
- /// The store the session is filed in after the turn.
+ /// The store the response id is filed in after the turn.
/// The session the turn advances.
/// The conversation inside .
/// The user words, or the approval answer, the turn runs on.
/// Where the request said the turn came from, or .
/// The id minted for this turn's answer.
- /// The id of the conversation, or when none names it.
+ /// The id of the conversation the answer reports, which is 's own id whenever the request resumed it directly, and the resolved owner when it resumed through a response id.
internal sealed record ResponsesTurn(
AgentCoreAgent Agent,
AgentCoreAgentSessionStore Sessions,
@@ -22,13 +22,19 @@ internal sealed record ResponsesTurn(
ChatMessage Input,
ConversationTurnOrigin? Origin,
string ResponseId,
- string? ConversationId)
+ string ConversationId)
{
- /// Files the session, under every id that names it now.
+ ///
+ /// Records that continues this conversation, unless the turn's own store write
+ /// was refused: the response id already names the session that saved that turn, and filing this one
+ /// would misname it. A turn whose write merely failed is still recorded here: nothing else holds the
+ /// response id, and store 0's own state outranks it on any later resume.
+ ///
public Task FileAsync(CancellationToken cancellationToken)
{
- return ResponsesSessionFiling.SaveAsync(
- Sessions, Agent, Session, ResponseId, ConversationId, Conversation.ConversationId, cancellationToken);
+ return Conversation.Ledger.Reads.Refused
+ ? Task.CompletedTask
+ : ResponsesSessionFiling.SaveAsync(Sessions, ResponseId, Conversation.ConversationId, cancellationToken);
}
}
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs
new file mode 100644
index 00000000..d23d1594
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs
@@ -0,0 +1,75 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AgentCore.Application.Runtime;
+using Microsoft.AspNetCore.Http;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// How the Responses path answers a turn it refused: another request held the conversation past the wait limit, or
+ /// saved the same turn of the conversation first.
+ ///
+ internal static class ResponsesTurnConflict
+ {
+ /// The error code of the answer, streamed or not.
+ public const string Code = "turn_conflict";
+
+ /// What the caller reads when another request saved the same turn first, after this turn ran.
+ public const string ConflictMessage =
+ "another request saved this turn of the conversation first, so this turn's reply was not saved. Reload the "
+ + "conversation and send the message again.";
+
+ /// What the caller reads when another request still held the conversation, so this turn never ran.
+ public const string BusyMessage =
+ "another request is still running a turn of this conversation, so this turn was refused before it ran. Wait "
+ + "for that turn to finish and send the message again.";
+
+ /// Picks the message for a reason.
+ /// The refusedReason of the turn's turn.refused event.
+ /// The message the caller reads.
+ public static string MessageFor(string refusedReason) =>
+ refusedReason == TurnRefusals.Busy ? BusyMessage : ConflictMessage;
+
+ ///
+ /// Ends a stream whose headers already left with the Responses error event, in place of the
+ /// response.completed the turn never reached.
+ ///
+ /// The request being answered.
+ /// The last frame the stream wrote, or when it wrote none.
+ /// Cancels the write.
+ public static async Task WriteEventAsync(HttpContext http, string? lastFrame, CancellationToken cancellationToken)
+ {
+ JsonObject error = new()
+ {
+ ["type"] = "error",
+ ["code"] = Code,
+ ["message"] = ConflictMessage,
+ ["param"] = null,
+ ["sequence_number"] = NextSequence(lastFrame),
+ };
+
+ await http.Response.WriteAsync(
+ string.Create(CultureInfo.InvariantCulture, $"event: error\ndata: {error.ToJsonString()}\n\n"),
+ cancellationToken)
+ .ConfigureAwait(false);
+ await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Reads the sequence number that follows the last frame's, or 0 when there was none.
+ private static int NextSequence(string? lastFrame)
+ {
+ int data = lastFrame?.IndexOf("data: ", StringComparison.Ordinal) ?? -1;
+ if (data < 0)
+ {
+ return 0;
+ }
+
+ using JsonDocument frame = JsonDocument.Parse(lastFrame![(data + "data: ".Length)..]);
+ return frame.RootElement.TryGetProperty("sequence_number", out JsonElement sequence)
+ && sequence.TryGetInt32(out int number)
+ ? number + 1
+ : 0;
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs
new file mode 100644
index 00000000..29ea16c8
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs
@@ -0,0 +1,20 @@
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ /// The lines the Responses path writes while it runs a turn.
+ /// Source-generated by , as CA1848 asks.
+ internal static partial class ResponsesTurnLog
+ {
+ /// The client aborted the stream, and filing the session outran its bound.
+ /// The logger of the Responses path.
+ /// The conversation whose session was not filed.
+ /// How long the filing was given.
+ [LoggerMessage(
+ EventId = 1,
+ Level = LogLevel.Warning,
+ Message = "the client aborted the stream of conversation '{ConversationId}', and filing its session "
+ + "took longer than {Bound}, so it was abandoned. A continuation of this turn may not resume.")]
+ public static partial void FilingAfterAbortTimedOut(ILogger logger, string conversationId, TimeSpan bound);
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs
index 7da5408d..94c06b92 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AgentCore.Application.Conversation;
+using AgentCore.Application.Runtime;
using AgentCore.Application.Runtime.Turn;
using AgentCore.Application.Tools;
using Microsoft.Agents.AI;
@@ -8,6 +9,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace AgentCore.AspNetCore.Endpoints
{
@@ -24,47 +27,109 @@ internal static async Task WriteAsync(
bool dialect,
CancellationToken cancellationToken)
{
+ // Started before the headers: only a started turn has opened its stored state and caught up on
+ // other sessions', so only then does the stage name the one the turn speaks in.
+ await using TurnRun run = await turn.Conversation
+ .StartTurnAsync(turn.Input, turn.Origin, cancellationToken).ConfigureAwait(false);
+
http.Response.StatusCode = StatusCodes.Status200OK;
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
-
- // The headers leave before the turn ends, so this one names the stage the turn speaks in.
http.Response.Headers[ResponsesEndpointRouteBuilderExtensions.StageHeaderName] = turn.Conversation.Stage;
// One turn's worth of ids: the pairing dies with the stream.
ToolCallNames toolNames = new();
- IAsyncEnumerable updates = StreamAgentUpdatesAsync(http, turn, dialect, toolNames, cancellationToken);
+ IAsyncEnumerable updates = StreamAgentUpdatesAsync(http, turn, run, dialect, toolNames, cancellationToken);
+ string? lastFrame = null;
- await foreach (string? frame in OpenAIResponses
- .WriteResponseStreamAsync(updates, turn.ResponseId, turn.ConversationId, cancellationToken)
- .ConfigureAwait(false))
+ try
{
- await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
+ await foreach (string? frame in OpenAIResponses
+ .WriteResponseStreamAsync(updates, turn.ResponseId, turn.ConversationId, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
- // Without this the reply arrives in one piece, which defeats the whole streaming path.
- await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
+ // Without this the reply arrives in one piece, which defeats the whole streaming path.
+ await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
+ lastFrame = frame;
+ }
+ }
+ catch (ConversationTurnConflictException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // The reply already streamed, so the refusal can only end the stream, never set its status.
+ await ResponsesTurnConflict.WriteEventAsync(http, lastFrame, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // The turn commits its words even when the host cancels mid-stream (design section 3), so the
+ // session still needs filing here, on a token that outlives the abort that cancelled the one above.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ await FileAfterAbortAsync(
+ turn.FileAsync,
+ turn.Conversation.ConversationId,
+ turn.Conversation.Time,
+ http.RequestServices.GetService()?.CreateLogger(typeof(ResponsesTurnStream))
+ ?? NullLogger.Instance)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ await turn.FileAsync(cancellationToken).ConfigureAwait(false);
+ }
}
+ }
+
+ ///
+ /// Files the session of a turn whose client has gone, within
+ /// : the same bound the rest of the work after
+ /// the reply runs under once the host's token no longer counts.
+ ///
+ /// Files the session; it reads the bound's token.
+ /// The conversation being filed, for the log.
+ /// The clock the bound runs on.
+ /// Where a filing that outruns the bound is reported.
+ ///
+ /// The wait ends at the bound even when ignores its token. A filing that
+ /// outruns it is logged and dropped, not thrown: the client that could read the error is gone.
+ ///
+ internal static async Task FileAfterAbortAsync(
+ Func file, string conversationId, TimeProvider time, ILogger logger)
+ {
+ TimeSpan bound = ConversationSession.TurnCompletionTimeout;
+ using CancellationTokenSource deadline = new(bound, time);
- await turn.FileAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await file(deadline.Token).WaitAsync(deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (deadline.IsCancellationRequested)
+ {
+ ResponsesTurnLog.FilingAfterAbortTimedOut(logger, conversationId, bound);
+ }
}
- /// Runs one streaming turn on the conversation, wrapping each update with the agent's id.
+ /// Reads one started turn's reply, wrapping each update with the agent's id.
private static async IAsyncEnumerable StreamAgentUpdatesAsync(
HttpContext http,
ResponsesTurn turn,
+ TurnRun run,
bool dialect,
ToolCallNames toolNames,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
TurnStreamFiles files = new();
- await foreach (ChatResponseUpdate? update in turn.Conversation
- .RunTurnMessageStreamingAtOriginAsync(turn.Input, turn.Origin, cancellationToken)
- .ConfigureAwait(false))
+ await foreach (ChatResponseUpdate? update in run.Updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
bool isNotice = update.Contents.OfType().Any();
+ // Carries no text of its own (E5): the caller reads it off the agentcore_message_committed
+ // dialect part instead, never as spoken or output text.
+ bool isCommitted = update.Contents.OfType().Any();
+
if (dialect)
{
foreach (TurnStreamPart part in TurnStreamParts.From(update, toolNames))
@@ -72,13 +137,13 @@ private static async IAsyncEnumerable StreamAgentUpdatesAsy
await WritePartLineAsync(http, part, cancellationToken).ConfigureAwait(false);
}
- if (!isNotice)
+ if (!isNotice && !isCommitted)
{
files.Note(update);
}
}
- if (isNotice)
+ if (isNotice || isCommitted)
{
continue;
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs b/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs
index 7d80cd58..0804f1bc 100644
--- a/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs
@@ -58,6 +58,15 @@ internal static IEnumerable From(ChatResponseUpdate update, Tool
yield return part;
}
}
+
+ foreach (TurnCommittedContent committed in update.Contents.OfType())
+ {
+ yield return new TurnStreamPart(TurnStreamPart.MessageCommitted, new MessageCommittedPayload
+ {
+ UserMessageId = committed.UserMessageId,
+ ReplyMessageId = committed.ReplyMessageId,
+ });
+ }
}
/// Frames one notice for the browser, or for a kind it does not show.
@@ -163,5 +172,8 @@ internal sealed record TurnStreamPart(string Member, object Payload)
/// One file the turn produced: a .
public const string File = "agentcore_file";
+
+ /// The ids one turn's commit wrote: a .
+ public const string MessageCommitted = "agentcore_message_committed";
}
}
diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
index f34457dc..6025d79d 100644
--- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
@@ -1,7 +1,7 @@
#nullable enable
-AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions
-AgentCore.AspNetCore.Conversation.IConversationTransportAdapter
-AgentCore.AspNetCore.Conversation.IConversationTransportAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate!
+AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions
+AgentCore.AspNetCore.Voice.IConversationTransportAdapter
+AgentCore.AspNetCore.Voice.IConversationTransportAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate!
AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients
AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.AgentCoreHttpClients(System.Net.Http.HttpMessageHandler? primaryHandler = null, Microsoft.Extensions.Logging.ILoggerFactory? loggers = null) -> void
AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.CreateClient(string! name) -> System.Net.Http.HttpClient!
@@ -25,9 +25,11 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.TimeProvider.get -> Sy
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.TimeProvider.set -> void
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.Cache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache?
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.Cache.set -> void
+AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.ResponseRetention.get -> System.TimeSpan?
+AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.ResponseRetention.set -> void
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseAuditSinks(params AgentCore.Application.Ports.IAuditSinkAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
-AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversation(params AgentCore.Application.Ports.IConversationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
-AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationSessions(System.Func! open) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
+AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversation(params AgentCore.AspNetCore.Voice.IConversationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
+AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationSessions(System.Func!, AgentCore.Application.Ports.IConversationSessions!>! open) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationStores(params AgentCore.Application.Ports.IConversationStoreAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func>! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
@@ -43,7 +45,7 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseModeration(params A
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseObservers(params AgentCore.Application.Ports.IConversationObserver![]! observers) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSkills(Microsoft.Agents.AI.AgentSkillsSource! source) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSkills(string! path) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
-AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSpeech(params AgentCore.Application.Ports.ISpeechAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
+AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSpeech(params AgentCore.AspNetCore.Voice.ISpeechAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseWorkspace(string! root) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseTelemetry(params AgentCore.Application.Ports.ITelemetryAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions
@@ -54,11 +56,12 @@ AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.AgentCoreAgentSessionStore(AgentCore.Application.Ports.IConversationStore! conversations) -> void
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.ContainsAsync(string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.ResolveConversationIdAsync(string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.GetSessionForConversationAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
-AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveSessionForConversationAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, string! conversationId, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
-AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SavePointerAsync(string! sessionStoreId, string! conversationId, string! target, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.CarriesText.get -> bool
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate!
@@ -67,8 +70,8 @@ AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRe
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.Kind.get -> string!
AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.TelnyxRelaySpeechAdapter() -> void
-const AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string!
-const AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string!
+const AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string!
+const AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string!
const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.MaxAttempts = 3 -> int
const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.PipelineName = "agentcore-http" -> string!
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/responses" -> string!
@@ -76,9 +79,9 @@ const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.Ent
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.StageHeaderName = "X-AgentCore-Stage" -> string!
const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRelayKind = "telnyx-relay" -> string!
const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.TelnyxRelayKind = "telnyx-relay" -> string!
-static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.EntryOf(Microsoft.AspNetCore.Http.HttpContext! http) -> string!
-static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
-static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
+static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryOf(Microsoft.AspNetCore.Http.HttpContext! http) -> string!
+static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
+static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddAgentCore(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddAgentCoreWebSockets(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
static AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.MapResponses(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
@@ -88,14 +91,117 @@ static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.Fi
static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.RequestDeadline -> System.TimeSpan
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseBlobStores(params AgentCore.Application.Ports.IBlobStoreAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.TimeZoneHeaderName = "X-AgentCore-Time-Zone" -> string!
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.ConversationSweepOptions() -> void
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Retention.get -> System.TimeSpan
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Retention.set -> void
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Interval.get -> System.TimeSpan
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Interval.set -> void
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.BatchSize.get -> int
-AgentCore.AspNetCore.Conversation.ConversationSweepOptions.BatchSize.set -> void
AgentCore.AspNetCore.Conversation.ConversationSweeper
-AgentCore.AspNetCore.Conversation.ConversationSweeper.ConversationSweeper(System.IServiceProvider! services, Microsoft.Extensions.Options.IOptions! options, System.TimeProvider! timeProvider, Microsoft.Extensions.Logging.ILogger! logger) -> void
-static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddConversationSweep(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+AgentCore.AspNetCore.Conversation.ConversationSweeper.ConversationSweeper(System.IServiceProvider! services, Microsoft.Extensions.Options.IOptions! options, System.TimeProvider! timeProvider, Microsoft.Extensions.Logging.ILogger! logger) -> void
+AgentCore.AspNetCore.Voice.ConversationChannel
+AgentCore.AspNetCore.Voice.ConversationChannel.$() -> AgentCore.AspNetCore.Voice.ConversationChannel!
+AgentCore.AspNetCore.Voice.ConversationChannel.ConversationChannel(AgentCore.AspNetCore.Voice.IConversationInputPort! Input, AgentCore.AspNetCore.Voice.IConversationOutputPort! Output) -> void
+AgentCore.AspNetCore.Voice.ConversationChannel.Deconstruct(out AgentCore.AspNetCore.Voice.IConversationInputPort! Input, out AgentCore.AspNetCore.Voice.IConversationOutputPort! Output) -> void
+AgentCore.AspNetCore.Voice.ConversationChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Voice.ConversationChannel.Equals(AgentCore.AspNetCore.Voice.ConversationChannel? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationChannel.Input.get -> AgentCore.AspNetCore.Voice.IConversationInputPort!
+AgentCore.AspNetCore.Voice.ConversationChannel.Input.init -> void
+AgentCore.AspNetCore.Voice.ConversationChannel.Output.get -> AgentCore.AspNetCore.Voice.IConversationOutputPort!
+AgentCore.AspNetCore.Voice.ConversationChannel.Output.init -> void
+AgentCore.AspNetCore.Voice.ConversationChannelContext
+AgentCore.AspNetCore.Voice.ConversationChannelContext.$() -> AgentCore.AspNetCore.Voice.ConversationChannelContext!
+AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationChannelContext(string! ConversationId, System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void
+AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationId.get -> string!
+AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationId.init -> void
+AgentCore.AspNetCore.Voice.ConversationChannelContext.CustomParameters.get -> System.Collections.Generic.IReadOnlyDictionary?
+AgentCore.AspNetCore.Voice.ConversationChannelContext.CustomParameters.init -> void
+AgentCore.AspNetCore.Voice.ConversationChannelContext.Deconstruct(out string! ConversationId, out System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void
+AgentCore.AspNetCore.Voice.ConversationChannelContext.Equals(AgentCore.AspNetCore.Voice.ConversationChannelContext? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationInput
+AgentCore.AspNetCore.Voice.ConversationInput.Barge
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.Barge(string! HeardText, System.TimeSpan PlayedDuration) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.Deconstruct(out string! HeardText, out System.TimeSpan PlayedDuration) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Barge? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.HeardText.get -> string!
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.HeardText.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.PlayedDuration.get -> System.TimeSpan
+AgentCore.AspNetCore.Voice.ConversationInput.Barge.PlayedDuration.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.ConversationInput(AgentCore.AspNetCore.Voice.ConversationInput! original) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Deconstruct(out string! Key) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Key.get -> string!
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Key.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Keypress(string! Key) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Deconstruct(out string! Text, out string! Language, out bool IsFinal) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.IsFinal.get -> bool
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.IsFinal.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Language.get -> string!
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Language.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Text.get -> string!
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Text.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Utterance(string! Text, string! Language, bool IsFinal) -> void
+AgentCore.AspNetCore.Voice.ConversationSpeechPairing
+AgentCore.AspNetCore.Voice.IConversationAdapter
+AgentCore.AspNetCore.Voice.IConversationAdapter.CarriesText.get -> bool
+AgentCore.AspNetCore.Voice.IConversationChannelFactory
+AgentCore.AspNetCore.Voice.IConversationChannelFactory.OpenAsync(AgentCore.AspNetCore.Voice.ConversationChannelContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Voice.IConversationInputPort
+AgentCore.AspNetCore.Voice.IConversationInputPort.ListenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+AgentCore.AspNetCore.Voice.IConversationOutputPort
+AgentCore.AspNetCore.Voice.IConversationOutputPort.BeginReply() -> void
+AgentCore.AspNetCore.Voice.IConversationOutputPort.CompleteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Voice.IConversationOutputPort.SpeakAsync(string! fragment, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Voice.IConversationOutputPort.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+AgentCore.AspNetCore.Voice.ISpeechAdapter
+AgentCore.AspNetCore.Voice.ConversationInput.Started
+AgentCore.AspNetCore.Voice.ConversationInput.Started.Deconstruct(out string! ConversationId) -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Started? other) -> bool
+AgentCore.AspNetCore.Voice.ConversationInput.Started.ConversationId.get -> string!
+AgentCore.AspNetCore.Voice.ConversationInput.Started.ConversationId.init -> void
+AgentCore.AspNetCore.Voice.ConversationInput.Started.Started(string! ConversationId) -> void
+abstract AgentCore.AspNetCore.Voice.ConversationInput.$() -> AgentCore.AspNetCore.Voice.ConversationInput!
+override AgentCore.AspNetCore.Voice.ConversationChannel.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationChannel.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationChannel.ToString() -> string!
+override AgentCore.AspNetCore.Voice.ConversationChannelContext.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationChannelContext.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationChannelContext.ToString() -> string!
+override AgentCore.AspNetCore.Voice.ConversationInput.Barge.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Barge!
+override AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.Barge.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationInput.Barge.ToString() -> string!
+override AgentCore.AspNetCore.Voice.ConversationInput.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Keypress!
+override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.ToString() -> string!
+override AgentCore.AspNetCore.Voice.ConversationInput.ToString() -> string!
+override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Utterance!
+override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.ToString() -> string!
+static AgentCore.AspNetCore.Voice.ConversationChannel.operator !=(AgentCore.AspNetCore.Voice.ConversationChannel? left, AgentCore.AspNetCore.Voice.ConversationChannel? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationChannel.operator ==(AgentCore.AspNetCore.Voice.ConversationChannel? left, AgentCore.AspNetCore.Voice.ConversationChannel? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationChannelContext.operator !=(AgentCore.AspNetCore.Voice.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.ConversationChannelContext? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationChannelContext.operator ==(AgentCore.AspNetCore.Voice.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.ConversationChannelContext? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Barge.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.ConversationInput.Barge? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Barge.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.ConversationInput.Barge? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Keypress.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.ConversationInput.Keypress? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Keypress.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.ConversationInput.Keypress? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Utterance.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.ConversationInput.Utterance? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Utterance.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.ConversationInput.Utterance? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.operator !=(AgentCore.AspNetCore.Voice.ConversationInput? left, AgentCore.AspNetCore.Voice.ConversationInput? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.operator ==(AgentCore.AspNetCore.Voice.ConversationInput? left, AgentCore.AspNetCore.Voice.ConversationInput? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationSpeechPairing.Validate(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! conversation, AgentCore.Application.Configuration.Schema.SpeechProviderConfiguration! speech, AgentCore.AspNetCore.Voice.IConversationAdapter! selectedConversation) -> void
+virtual AgentCore.AspNetCore.Voice.ConversationInput.EqualityContract.get -> System.Type!
+virtual AgentCore.AspNetCore.Voice.ConversationInput.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
+virtual AgentCore.AspNetCore.Voice.ConversationInput.PrintMembers(System.Text.StringBuilder! builder) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.Started.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Started!
+override AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(object? obj) -> bool
+override AgentCore.AspNetCore.Voice.ConversationInput.Started.GetHashCode() -> int
+override AgentCore.AspNetCore.Voice.ConversationInput.Started.ToString() -> string!
+static AgentCore.AspNetCore.Voice.ConversationInput.Started.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.ConversationInput.Started? right) -> bool
+static AgentCore.AspNetCore.Voice.ConversationInput.Started.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.ConversationInput.Started? right) -> bool
+override sealed AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
+override sealed AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
+override sealed AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
+override sealed AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
diff --git a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs
index 93ed2933..26993bc5 100644
--- a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs
+++ b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs
@@ -1,17 +1,15 @@
-using System.Text.Json;
using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
namespace AgentCore.AspNetCore.Sessions
{
///
- /// The framework's session seam over the continuation map that lives in the conversation store.
+ /// The framework's session seam over the conversation store.
///
public sealed class AgentCoreAgentSessionStore : AgentSessionStore
{
- private const string PointerProperty = "$continuationPointer";
-
private readonly IConversationStore _conversations;
/// Creates the seam over one conversation store.
@@ -23,65 +21,80 @@ public AgentCoreAgentSessionStore(IConversationStore conversations)
_conversations = conversations;
}
- /// Reads whether anything is filed under one continuation id.
+ /// Reads whether a continuation id names a conversation, directly or through a response id.
/// The conversation id or response id to look up.
/// Cancels the read.
- /// when a later load would find an envelope.
+ /// when a later resume would find the conversation to open.
public async ValueTask ContainsAsync(string sessionStoreId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(sessionStoreId);
- return await _conversations.GetContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false)
- is not null;
+ return await ResolveConversationIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is not null;
}
- ///
- public override ValueTask SaveSessionAsync(
- AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
+ /// Resolves a continuation id to the conversation it names.
+ /// A conversation id, or a response id that continues one.
+ /// Cancels the reads.
+ ///
+ /// The conversation id: itself when it already names a conversation
+ /// row, the row a response id continues, or when it names neither.
+ ///
+ public async ValueTask ResolveConversationIdAsync(string sessionStoreId, CancellationToken cancellationToken = default)
{
- return SaveSessionForConversationAsync(agent, sessionStoreId, sessionStoreId, session, cancellationToken);
+ ArgumentNullException.ThrowIfNull(sessionStoreId);
+
+ if (await _conversations.FindContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is { } owner)
+ {
+ return owner;
+ }
+
+ return await _conversations.GetAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is not null
+ ? sessionStoreId
+ : null;
}
- /// Files the session under one continuation id, on behalf of one conversation.
- /// The agent the session belongs to.
- /// The conversation id or response id to file the envelope under.
- /// The conversation the continuation belongs to.
- /// The session to file.
+ /// Records that one response id continues one conversation.
+ /// The id minted for a turn's answer.
+ /// The conversation the response id continues.
/// Cancels the write.
- public async ValueTask SaveSessionForConversationAsync(
- AIAgent agent, string sessionStoreId, string conversationId, AgentSession session, CancellationToken cancellationToken)
+ public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(agent);
- ArgumentNullException.ThrowIfNull(sessionStoreId);
+ ArgumentNullException.ThrowIfNull(responseId);
ArgumentNullException.ThrowIfNull(conversationId);
- ArgumentNullException.ThrowIfNull(session);
- JsonElement envelope = await agent
- .SerializeSessionAsync(session, cancellationToken: cancellationToken)
- .ConfigureAwait(false);
-
- await _conversations.SaveContinuationAsync(sessionStoreId, conversationId, envelope, cancellationToken).ConfigureAwait(false);
+ return _conversations.SaveContinuationAsync(responseId, conversationId, cancellationToken);
}
///
- /// Points one continuation id at another, in place of a full envelope. A resume through the
- /// pointer id always reads the latest state, without a second copy of it.
+ /// Records that a response id continues the conversation MAF's SaveSessionAsync path just
+ /// filed a session for. Nothing is written when the id already names the conversation itself: a
+ /// conversation resumes through without a row of its own.
///
- /// The continuation id the pointer is filed under.
- /// The conversation the pointer belongs to.
- /// The continuation id holding the full envelope.
+ /// The agent the session belongs to.
+ /// The id the caller is filing under.
+ /// The session this store opened onto.
/// Cancels the write.
- public ValueTask SavePointerAsync(
- string sessionStoreId, string conversationId, string target, CancellationToken cancellationToken)
+ /// is not one created.
+ public override ValueTask SaveSessionAsync(
+ AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
{
+ ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(sessionStoreId);
- ArgumentNullException.ThrowIfNull(conversationId);
- ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(session);
+
+ if (session is not AgentCoreAgentSession own)
+ {
+ throw new ArgumentException(
+ $"Incompatible session type: {session.GetType()}. Only a session {nameof(AgentCoreAgent)} "
+ + "created can be filed.",
+ nameof(session));
+ }
- JsonElement pointer = JsonSerializer.SerializeToElement(
- new Dictionary { [PointerProperty] = target });
+ string conversationId = own.Conversation.ConversationId;
- return _conversations.SaveContinuationAsync(sessionStoreId, conversationId, pointer, cancellationToken);
+ return string.Equals(sessionStoreId, conversationId, StringComparison.Ordinal)
+ ? ValueTask.CompletedTask
+ : _conversations.SaveContinuationAsync(sessionStoreId, conversationId, cancellationToken);
}
///
@@ -91,38 +104,32 @@ public override async ValueTask GetSessionAsync(
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(sessionStoreId);
- JsonElement? envelope = await _conversations.GetContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
-
- if (envelope is null)
- {
- return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
- }
-
- if (TryReadPointerTarget(envelope.Value, out string? target))
- {
- envelope = await _conversations.GetContinuationAsync(target, cancellationToken).ConfigureAwait(false)
- ?? throw new InvalidOperationException(
- $"Continuation '{sessionStoreId}' points at '{target}', which no longer names an envelope.");
- }
+ // Unknown to store 0: opened under the id itself, not a random one, so this key resolves by
+ // GetAsync forever after, and MAF's own SaveSessionAsync writes no response row for it at all
+ // — a made-up id would need one, and the retention sweep would eventually take it.
+ string conversationId = await ResolveConversationIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false)
+ ?? sessionStoreId;
- return await agent
- .DeserializeSessionAsync(envelope.Value, cancellationToken: cancellationToken)
- .ConfigureAwait(false);
+ return await GetSessionForConversationAsync(agent, conversationId, cancellationToken).ConfigureAwait(false);
}
- /// Reads the target a pointer row names, or answers for a full envelope.
- private static bool TryReadPointerTarget(JsonElement envelope, out string target)
+ /// Opens the conversation a resolved id names.
+ /// The agent to open the conversation on.
+ /// The conversation to open, or start under that id if it is new.
+ /// Cancels the open.
+ /// is not one .
+ public ValueTask GetSessionForConversationAsync(
+ AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
- if (envelope.ValueKind == JsonValueKind.Object
- && envelope.TryGetProperty(PointerProperty, out JsonElement value)
- && value.ValueKind == JsonValueKind.String)
- {
- target = value.GetString()!;
- return true;
- }
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentNullException.ThrowIfNull(conversationId);
- target = "";
- return false;
+ return agent is AgentCoreAgent own
+ ? own.CreateSessionAsync(conversationId, cancellationToken)
+ : throw new ArgumentException(
+ $"Incompatible agent type: {agent.GetType()}. Only {nameof(AgentCoreAgent)} can resume a "
+ + "conversation by id.",
+ nameof(agent));
}
///
diff --git a/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs b/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs
deleted file mode 100644
index bea3aea4..00000000
--- a/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using AgentCore.AspNetCore.DependencyInjection.Startup;
-using AgentCore.Application.Sessions.Memory;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using AgentCore.Application.Ports;
-
-namespace AgentCore.AspNetCore.Sessions
-{
- ///
- /// Runs the idle sweep of every for as long as the host is up.
- ///
- internal sealed class ConversationSessionSweeper(
- IServiceProvider services, TimeProvider timeProvider, ILogger logger) : BackgroundService
- {
- /// How often the sweep runs.
- private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
-
- ///
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- EntryRegistry entries = services.GetRequiredService();
-
- using PeriodicTimer timer = new(Interval, timeProvider);
-
- while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
- {
- // A sweep that throws must not end the loop: the sessions it could not close are still
- // held, and the next tick is the only thing that will try them again.
- try
- {
- List? faults = null;
- foreach (IConversationSessions store in entries.ConversationSessions.Values)
- {
- if (store is not InMemoryConversationSessions memory)
- {
- continue;
- }
-
- try
- {
- await memory.SweepAsync(stoppingToken).ConfigureAwait(false);
- }
- catch (Exception fault) when (fault is not OperationCanceledException)
- {
- faults ??= [];
- faults.Add(fault);
- }
- }
-
- if (faults is { Count: > 0 })
- {
- throw new AggregateException(faults);
- }
- }
- catch (Exception fault) when (fault is not OperationCanceledException)
- {
- ConversationSessionSweeperLog.SweepFaulted(logger, fault);
- }
- }
- }
- }
-
- /// Every line the idle sweep writes.
- internal static partial class ConversationSessionSweeperLog
- {
- /// A sweep could not end one or more of the sessions it visited.
- /// The logger of the sweeper.
- /// The cause, or the causes of a sweep that met more than one.
- [LoggerMessage(
- EventId = 1,
- Level = LogLevel.Error,
- Message = "the idle sweep could not end every session it visited, so those sessions are still held.")]
- public static partial void SweepFaulted(ILogger logger, Exception exception);
- }
-}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs
new file mode 100644
index 00000000..656df3de
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs
@@ -0,0 +1,13 @@
+namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
+{
+ /// One item queued for the write loop, carrying the reply generation it was written under.
+ ///
+ /// The reply generation was queued under, or for a
+ /// frame no reply owns — nothing writes one of those yet, but the write loop's gate only applies
+ /// when a generation is present, so a future frame outside the reply lifecycle (a close handoff,
+ /// for one) can opt out by carrying none. This type never reaches :
+ /// only is serialized, so nothing here changes the wire.
+ ///
+ /// The frame to serialize and send: a today.
+ internal readonly record struct OutboundItem(long? Generation, object Frame);
+}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs
new file mode 100644
index 00000000..fe091591
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs
@@ -0,0 +1,14 @@
+using System.Net.WebSockets;
+
+namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
+{
+ /// The relay broke the contract, and the socket must close with a reason.
+ /// The close status the vendor should see.
+ /// Why the endpoint refused.
+ internal sealed class RelayProtocolException(WebSocketCloseStatus status, string message)
+ : Exception(message)
+ {
+ /// Gets the status the socket closes with.
+ public WebSocketCloseStatus Status { get; } = status;
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs
index 7fcc6bc8..93e61cee 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs
@@ -1,12 +1,6 @@
using System.Net.WebSockets;
-using System.Runtime.CompilerServices;
-using System.Text.Json;
-using System.Threading.Channels;
-using AgentCore.Application.Conversation;
-using AgentCore.Application.Ports;
-using AgentCore.Application.Runtime;
-using AgentCore.AspNetCore.Conversation;
using AgentCore.AspNetCore.DependencyInjection;
+using AgentCore.AspNetCore.Voice;
using AgentCore.Domain.Audit;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -19,58 +13,24 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
/// One socket, one conversation.
///
///
- ///
- /// Three tasks run for the life of the socket. The read loop parses frames and never waits for a
- /// turn, because a turn that blocked the loop would make the interrupt frame unreachable
- /// and barge-in impossible. The write loop is the only caller of
- /// ,
- /// because a WebSocket supports exactly one send at a time. The turn task streams the reply into
- /// the write channel.
- ///
- ///
- /// All three read one connection-scoped token, cancelled once for any reason the socket is going
- /// away: the request aborted, the host is stopping, or one of the three tasks itself ended.
- /// Nothing here waits on HttpContext.RequestAborted once teardown starts, because ASP.NET
- /// Core recycles the context once the response completes and that token can then throw or name a
- /// later request.
- ///
- ///
- /// The adapter owns the vendor schema and the core does not, so D8 holds: this class translates
- /// frames onto and nothing else.
- ///
- ///
- /// One object fills both halves of the conversation's , because one socket
- /// carries both directions for this vendor. Nothing above the ports can tell, and nothing may ask.
- /// The speaking side is below: every reply reaches the write loop
- /// through . The last of the two barge-in gates is this class's business
- /// and nobody else's — speaks in turn ids and never hands one over,
- /// so this class keeps a monotonic reply generation instead, stamps every item it queues with the
- /// generation it was queued under, and raises that generation in . The write
- /// loop then drops any item at or below the raised one, which is the same comparison the turn ids
- /// used to answer, asked in the transport's own vocabulary.
- ///
- ///
- /// One narrowing comes with that seam, and it is written down rather than hidden: a fragment the
- /// arbiter had already decided to speak, but which only reaches after
- /// raised the generation, is stamped with the new one and reaches the
- /// caller. A port carries no reply identity to tell that fragment from the first fragment of the
- /// next reply, and the window is the few instructions between the arbiter's own check and that conversation.
- ///
+ /// This type owns the socket and its teardown. and
+ /// translate the wire, and
+ /// owns the turns.
///
- internal sealed class TelnyxRelayConnection : IConversationInputPort, IConversationOutputPort
+ internal sealed class TelnyxRelayConnection : IAsyncDisposable
{
private readonly TelnyxRelayOptions _options;
private readonly ILogger _logger;
- private readonly Channel _outbound;
+ private readonly TelnyxRelayInput _input;
+
+ private readonly TelnyxRelayOutput _output = new();
+
+ private readonly VoiceConversationLoop _loop;
private readonly CancellationTokenSource _cancellation;
- // The same token every loop reads, captured once. Read off the field and never off
- // _cancellation.Token by anything that can outlive teardown: RunAsync disposes that source on
- // its way out, and a disposed source's Token getter throws, while the struct captured here goes
- // on answering "already cancelled" for as long as anyone holds it.
private readonly CancellationToken _connectionToken;
private readonly IHostApplicationLifetime _lifetime;
@@ -79,42 +39,11 @@ internal sealed class TelnyxRelayConnection : IConversationInputPort, IConversat
private readonly JsonWebSocketPump _pump;
- // The last of the two barge-in gates, in this connection's own vocabulary. SpeakAsync stamps
- // every item it queues with this counter plus one and StopAsync raises the counter itself when a
- // barge-in stops the reply; the write loop then drops anything at or below it. It lives on the
- // connection, alongside the channel the write loop reads, because the write loop is the one that
- // reads it and a second setup frame must not reset what a barge-in already cut off.
- private long _replyGeneration;
-
- // Zero until something takes the inbound stream, and one afterwards, forever. ListenAsync
- // promises one consumer for the life of the port, and this is what a second call trips over.
- private int _listening;
-
- private volatile ConversationSession? _session;
-
- // Resolved once per socket from the stamped entry, then cached: every session open, touch,
- // and close on this connection answers on the same entry.
- private volatile IConversationSessions? _entrySessions;
+ private readonly JsonWebSocketSender _sender;
private const string BeforeSetupConversationId = "(before setup)";
- // volatile for the same reason _session is: the read loop assigns it when the setup frame
- // arrives, and teardown reads it from another task to find the last turn.
- private volatile ConversationTurnArbiter? _arbiter;
-
- private bool _loggedPromptBeforeSetup;
-
- private bool _loggedMalformedInterrupt;
-
- private bool _loggedSecondSetup;
-
- /// Reads this socket's entry session store, resolved once from the URL's entry.
- /// The store for the entry the URL named.
- private IConversationSessions EntrySessions()
- {
- return _entrySessions
- ?? throw new InvalidOperationException("This socket resolved no session store.");
- }
+ private const string ConversationInUseDescription = "the conversation is in use by another entry";
private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOptions options, ILogger logger)
{
@@ -122,10 +51,6 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt
_logger = logger;
- // A dropped socket ends this conversation, and so does the host shutting down. Both stop the read
- // loop, the write loop, and any turn the same way, through the one token everything below
- // reads. IHostApplicationLifetime is resolved here, from the request's own provider, and
- // never at MapTelnyxRelay time: nothing is bound to it until a conversation actually arrives.
_lifetime = http.RequestServices.GetRequiredService();
_cancellation = CancellationTokenSource.CreateLinkedTokenSource(
@@ -134,37 +59,16 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt
_connectionToken = _cancellation.Token;
- // AddAgentCore registers this from options.TimeProvider, or TimeProvider.System when the
- // host bound none — the same clock ConversationSessionFactory already reads for conversationDurationSeconds.
- // Resolving it here, rather than reading TimeProvider.System directly, is what lets a test
- // own the idle deadline below by binding that one option.
TimeProvider timeProvider = http.RequestServices.GetRequiredService();
- // Bounded, so a slow relay slows the turn loop instead of growing a queue without a bound.
- _outbound = Channel.CreateBounded(new BoundedChannelOptions(256)
- {
- FullMode = BoundedChannelFullMode.Wait,
- SingleReader = true,
- SingleWriter = false,
- });
-
- // The observer is vendor-free, so the two faults that are the relay's own doing reach it
- // through ClassifyTelnyxFault rather than through a catch clause of its own. The conversation id is
- // a function because the session only exists once the setup frame has arrived, which is
- // after this constructor and after some of the lines the observer logs.
+ _input = new TelnyxRelayInput(logger, () => ConversationIdForLog);
+
_observer = new ConnectionTaskObserver(
- () => _session?.ConversationId ?? BeforeSetupConversationId,
+ () => ConversationIdForLog,
(conversationId, taskName) => TelnyxRelayLog.TeardownTimedOut(_logger, conversationId, taskName),
LogFault,
ClassifyTelnyxFault);
- // The pump owns the socket from here on: the read loop, the write loop, and the one close
- // this connection makes are all its, and none of the three names a vendor. The three seams
- // that would have are the delegates below — the exception the pump raises when the relay
- // breaks the contract, so DetermineCloseStatus still recognises it off the read loop's own
- // task, and the three log lines that name the relay by name. Each log callback closes over
- // _session for the same reason the observer's own conversationId does: the id only exists once the
- // setup frame has arrived, which is after this constructor.
_pump = new JsonWebSocketPump(
socket,
new JsonWebSocketPumpOptions(
@@ -172,19 +76,34 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt
_options.IdleTimeout,
_options.CloseTimeout,
(status, message) => new RelayProtocolException(status, message),
- frameType => TelnyxRelayLog.UnknownFrameType(_logger, frameType, _session?.ConversationId ?? BeforeSetupConversationId),
- frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, _session?.ConversationId ?? BeforeSetupConversationId),
- () => TelnyxRelayLog.IdleTimeoutReached(_logger, _session?.ConversationId ?? BeforeSetupConversationId)),
+ frameType => TelnyxRelayLog.UnknownFrameType(_logger, frameType, ConversationIdForLog),
+ frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, ConversationIdForLog),
+ () => TelnyxRelayLog.IdleTimeoutReached(_logger, ConversationIdForLog)),
timeProvider,
_connectionToken);
+
+ _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, _connectionToken);
+
+ _loop = new VoiceConversationLoop(
+ http.RequestServices.GetRequiredService().Entries.Sessions,
+ ConversationEndpointRouteBuilderExtensions.EntryOf(http),
+ _output,
+ _observer,
+ timeProvider,
+ logger,
+ _options.CloseTimeout,
+ _connectionToken,
+ _options.Voice);
}
+ private string ConversationIdForLog => _loop?.ConversationId ?? BeforeSetupConversationId;
+
/// Runs one conversation to its end.
/// The request that carried the handshake.
/// The accepted socket.
/// What the endpoint may do.
/// A task that completes when the socket is closed.
- public static Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptions options)
+ public static async Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptions options)
{
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(socket);
@@ -194,43 +113,25 @@ public static Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptio
.GetRequiredService()
.CreateLogger("AgentCore.TelnyxRelay");
- TelnyxRelayConnection connection = new(http, socket, options, logger)
- {
- _entrySessions = http.RequestServices
- .GetRequiredService()
- .Entries.ForSessions(ConversationEndpointRouteBuilderExtensions.EntryOf(http))
- };
+ await using TelnyxRelayConnection connection = new(http, socket, options, logger);
- return connection.RunAsync();
+ await connection.RunAsync().ConfigureAwait(false);
}
private async Task RunAsync()
{
- // Both loops belong to the pump, and both seams into this class are named methods rather
- // than bodies: ParseFrame is the vendor's own reader, and DispatchAsync is what one parsed
- // frame means to this conversation. The cast is safe because ParseFrame is the only thing that ever
- // puts a frame into a FrameOutcome, and it only ever puts a RelayFrame there.
- Task reading = _pump.ReadLoopAsync(ParseFrame, frame => DispatchAsync((RelayFrame)frame));
- Task writing = _pump.WriteLoopAsync(_outbound.Reader, EncodeOutbound);
+ Task reading = _pump.ReadLoopAsync(ParseFrame, AcceptAsync);
+ Task writing = _sender.WriteLoopAsync(_output.Reader, _output.Encode, _output.SendGate);
+ Task listening = _loop.RunAsync(_input.ListenAsync(CancellationToken.None));
try
{
- _ = await Task.WhenAny(reading, writing).ConfigureAwait(false);
+ _ = await Task.WhenAny(reading, writing, listening).ConfigureAwait(false);
}
finally
{
- // The disposal below must run on every exit from here down, including a throw out of
- // Cancel() itself or IConversationSessions.CloseAsync: _cancellation links to ApplicationStopping,
- // which lives for the whole process, and only Dispose() releases that registration.
try
{
- // One of the two loops just ended, for whatever reason, so the socket is going
- // away. Cancelling here stops the other loop's wait and any turn still streaming,
- // rather than leaving them to read a recycled HttpContext.RequestAborted after this
- // method returns. Cancel() itself is guarded: it runs with throwOnFirstException:
- // false, so every callback still runs and both loops are already cancelled before a
- // callback fault can reach this catch, and losing the rest of teardown below would
- // otherwise strand this conversation in the store for the life of the process.
try
{
await _cancellation.CancelAsync().ConfigureAwait(false);
@@ -239,113 +140,41 @@ private async Task RunAsync()
{
ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.CancellationFaulted(
_logger,
- _session?.ConversationId ?? BeforeSetupConversationId,
+ ConversationIdForLog,
fault));
}
- // The read loop must actually finish — and any session it was still creating must be
- // visible on this task — before _session is read for removal below. Awaiting it here,
- // rather than trusting Task.WhenAny already returned, is also what surfaces a fault
- // that raced the write loop instead of leaving it unobserved.
+ // Nothing queued from here on can be sent, and a speech still blocked on a full queue would
+ // otherwise hold the drain below until its interruption backstop.
+ _output.Complete();
+
await _observer.ObserveAsync(reading, ConnectionTaskKind.ReadLoop).ConfigureAwait(false);
- // The turn must stop writing before the channel is completed under it. Completing the
- // writer first would let a write that was already in flight throw ChannelClosedException
- // into a task nothing observes. Bounded: cancellation reaches a well-behaved turn, but
- // a turn that honours cancellation yet takes longer than CloseTimeout to unwind — or a
- // chat client that ignores its token altogether, such as BlockingChatClient in the test
- // fakes — must not be able to wedge teardown forever.
- //
- // The last turn is read through ConversationTurnArbiter.CurrentTurn, which takes the same
- // lock every writer of it takes, and never off a field directly: the arbiter's own
- // RunPendingPrompt can reassign it from a turn's own task, off the read loop, so
- // teardown here is no longer the only writer's own reader. That property's remarks
- // carry the rest of the reasoning, including what stops the reverse case — a fresh
- // turn appearing after teardown already read it. Null before the first setup frame,
- // because the arbiter is built with the session.
- Task lastTurn = _arbiter?.CurrentTurn ?? Task.CompletedTask;
-
- await _observer.ObserveAsync(lastTurn, ConnectionTaskKind.Turn, _options.CloseTimeout).ConfigureAwait(false);
-
- _ = _outbound.Writer.TryComplete();
-
- // Both reading and lastTurn are fully observed by this point — ObserveAsync already
- // awaited each of them inside its own try, which is what makes it safe to read
- // .Exception here without raising a fresh UnobservedTaskException. Determined now,
- // after teardown already knows how the read loop and the last turn each ended, and
- // before the one CloseOutputAsync call the pump makes, because a status decided
- // any later could not still change what the vendor already received. writing is not
- // yet observed at this point — it is only awaited after the close below — so passing
- // it in only lets DetermineCloseStatus see a fault it already has, never one that
- // has not happened yet; see the remarks on that method for what this can and cannot
- // catch.
- (WebSocketCloseStatus status, string? description) = DetermineCloseStatus(reading, lastTurn, writing);
-
- // No bounded wait for the write loop here: _cancellation already cancelled above, and
- // the write loop reads the channel with that same token, so it is already unblocking
- // on its own. CloseAsync is what actually ends a send stuck on relay backpressure — it
- // aborts the socket on its own timeout — so the write loop is observed after it, not
- // waited for before it. A throw out of CloseAsync itself must not skip that
- // observation or the store removal below, so it is caught and logged here instead of
- // left to propagate.
+ _input.Complete();
+
+ await _observer.ObserveAsync(listening, ConnectionTaskKind.ReadLoop).ConfigureAwait(false);
+
+ Task drained = _loop.DrainAsync();
+
+ await _observer.ObserveAsync(drained, ConnectionTaskKind.SessionClose, _options.CloseTimeout).ConfigureAwait(false);
+
+ (WebSocketCloseStatus status, string? description) = DetermineCloseStatus(reading, listening, drained, writing);
+
try
{
- await _pump.CloseAsync(status, description).ConfigureAwait(false);
+ await _sender.CloseAsync(status, description).ConfigureAwait(false);
}
catch (Exception fault)
{
ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.CloseFaulted(
_logger,
- _session?.ConversationId ?? BeforeSetupConversationId,
+ ConversationIdForLog,
fault));
}
await _observer.ObserveAsync(writing, ConnectionTaskKind.WriteLoop).ConfigureAwait(false);
- if (_session is { } session)
- {
- // The chain closes before the session leaves the store, and never after: §11
- // item 6 makes conversation.ended the last event of every conversation, and the session is what
- // writes it. The reason comes from the status just decided rather than from a
- // second reading of the same tasks, so the vendor's close frame and the audit
- // row can never disagree about how this conversation ended. A conversation that already closed
- // its own chain from a terminal stage answers false here and keeps the
- // agent.completed it wrote: EndConversation is idempotent, and nothing on this path may
- // give one conversation two endings.
- //
- // Guarded for the same reason CloseAsync above is. §7.1 forbids teardown
- // throwing out of the request handler, and the close below must run whatever
- // happens here — it is what waits for the words the last turn still owed
- // store 1, and nothing else can. The event is
- // handed to observers that swallow their own faults and the reason is a member
- // of a closed set this class picks itself, so the only cause left is the clock
- // the session reads; a missing last event is a gap in one chain, and losing the
- // rest of teardown would be a leak in every conversation after it.
- try
- {
- _ = session.EndConversation(EndReasonOf(status));
- }
- catch (Exception fault)
- {
- ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.ConversationEndFaulted(
- _logger,
- session.ConversationId,
- fault));
- }
-
- // Closing waits for the words this conversation still owes store 1 and only then drops
- // the session; IConversationSessions.CloseAsync owns that order.
- //
- // Bounded and swallowed for the same reason the close above is: a store that
- // stops answering must cost this conversation its last rows, not wedge teardown and
- // strand the session in the store for the life of the process.
- await _observer
- .ObserveAsync(
- CloseSessionAsync(session.ConversationId),
- ConnectionTaskKind.SessionClose,
- _options.CloseTimeout)
- .ConfigureAwait(false);
- }
+ await _loop.EndAsync(EndReasonOf(status)).ConfigureAwait(false);
}
finally
{
@@ -354,23 +183,19 @@ await _observer
}
}
- /// Ends one conversation, as a task the observer can bound and watch.
- /// The conversation that ended.
- /// A task that completes once the words are written and the session is gone.
- private async Task CloseSessionAsync(string conversationId)
- {
- await EntrySessions().CloseAsync(conversationId, CancellationToken.None).ConfigureAwait(false);
- }
-
/// Works out the status and the description the vendor sees on the close frame.
///
/// The read loop's task, already fully observed by .
///
- ///
- /// The last turn's task, already fully observed by .
+ ///
+ /// The voice loop's task, already fully observed. A fault there closes the socket the same way a fault in
+ /// the read loop does.
+ ///
+ ///
+ /// The speech teardown's task, already fully observed by .
///
///
- /// The write loop's task. Unlike and , this
+ /// The write loop's task. Unlike and , this
/// one is not yet observed — RunAsync only awaits it after the close this method's result
/// feeds. Its is still safe to read without observing it first:
/// that flag reflects the task's current state the moment it is read, and reading it does not
@@ -378,42 +203,10 @@ private async Task CloseSessionAsync(string conversationId)
/// awaiting or reading would.
///
/// The status to close with, and the description to send alongside it.
- ///
- ///
- /// Both and are already completed and
- /// already observed by the time this runs — ObserveAsync awaited each of them inside its own
- /// try — so reading their here raises nothing new; it only reads
- /// what already happened.
- ///
- ///
- /// The checks are ordered by how specific each cause is. A
- /// names exactly why the endpoint refused the conversation, so it wins over everything else. A turn or
- /// the write loop faulting with anything other than its own cancellation is this connection's
- /// own defect, not the caller's or the vendor's, so either counts before the host-stopping check
- /// below even notices that is, by this point in teardown, always
- /// already cancelled for every possible reason at once. Host-stopping is checked directly
- /// against rather than against which task happened to notice the
- /// cancellation first, because once teardown's own call to _cancellation.Cancel() has
- /// run, every task is cancelled regardless of why teardown started, and none of their own
- /// completions can still tell "the host is stopping" apart from "the other loop just ended, so
- /// this cancelled too." A dropped socket with no close frame —
- /// — and a read loop that simply
- /// returned once it saw the relay's own close frame both fall through to the same default: an
- /// ordinary end of conversation.
- ///
- ///
- /// The check only catches a write loop that has already faulted at
- /// the moment this method runs. It is not exhaustive: the write loop is still running at this
- /// point in the common case, and a fault it raises after this line — right up through the
- /// SendAsync the close itself makes — cannot change a status already chosen and already
- /// on its way to the vendor. That gap is an accepted limit of observing
- /// before the close rather than after it, which the teardown order this method must not disturb
- /// already requires.
- ///
- ///
private (WebSocketCloseStatus Status, string? Description) DetermineCloseStatus(
Task reading,
- Task lastTurn,
+ Task listening,
+ Task drained,
Task writing)
{
if (reading.IsFaulted && reading.Exception.GetBaseException() is RelayProtocolException protocol)
@@ -421,21 +214,27 @@ private async Task CloseSessionAsync(string conversationId)
return (protocol.Status, protocol.Message);
}
- if (lastTurn.IsFaulted || writing.IsFaulted)
+ if (drained.IsFaulted || writing.IsFaulted)
{
return (WebSocketCloseStatus.InternalServerError, null);
}
+ if (_loop.WasRefused)
+ {
+ return (WebSocketCloseStatus.PolicyViolation, ConversationInUseDescription);
+ }
+
if (_lifetime.ApplicationStopping.IsCancellationRequested)
{
return (WebSocketCloseStatus.EndpointUnavailable, null);
}
- if (reading.IsFaulted
- && reading.Exception.GetBaseException() is not WebSocketException
- {
- WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely,
- })
+ if (listening.IsFaulted
+ || (reading.IsFaulted
+ && reading.Exception.GetBaseException() is not WebSocketException
+ {
+ WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely,
+ }))
{
return (WebSocketCloseStatus.InternalServerError, null);
}
@@ -446,55 +245,6 @@ private async Task CloseSessionAsync(string conversationId)
/// Reads the ending one close status reports, as one member of the closed set.
/// What already decided this conversation closes with.
/// The reason the last event of the chain carries.
- ///
- ///
- /// §11 item 6 and T55/T56 ask every call to end its chain in conversation.ended with a reason from
- /// the closed set of four, and only this adapter can supply two of them: the turn loop closes its
- /// own chain with when the stage machine reaches a
- /// terminal stage, and belongs to the Conversation Control
- /// path of §4.3 that voice slice 2 adds and this build does not have. What is left for a socket
- /// that ended is the caller, or a fault.
- ///
- ///
- /// The answer is read off the status rather than recomputed from the three tasks, so one decision
- /// serves both the close frame the vendor sees and the row a report reads years later. That also
- /// keeps this method total over a status set only chooses from.
- ///
- ///
- /// is a hang-up, not a fault, and that
- /// is the load-bearing decision here. A socket that stopped with no close frame is what
- /// already folds into
- /// alongside a read loop that saw the relay's own close frame — its remarks conversation both "an ordinary
- /// end of conversation" — and what already logs through
- /// at Information rather than as a defect.
- /// This method agrees with both, and it has to: a caller who hangs up and a caller whose phone
- /// loses signal look identical on this socket, the vendor never reconnects either of them, and
- /// the audit table would be worth very little if the ordinary way a conversation ends were counted as a
- /// system fault. §7.1's rule that a bad frame must never drop a conversation points the same way — the
- /// adapter's default reading of an ended socket is the conversation ending, not the endpoint failing.
- /// The one place this reading is contradicted is the remark on
- /// itself, which lists "a dropped relay socket" among its causes; that sentence predates the
- /// close-status reasoning above, and it is left alone here rather than edited, because the enum is
- /// the core's and this decision is the adapter's.
- ///
- ///
- /// The idle deadline lands here too, and for the same reason. A read loop that ends on
- /// IdleTimeout returns rather than throwing, so it reaches
- /// and is recorded as a hang-up — which is
- /// exactly what already says a silent socket is:
- /// a conversation that is already over.
- ///
- ///
- /// Everything else is , and that includes two endings worth
- /// naming. A protocol violation closes with the status
- /// carries: the relay broke the contract and this endpoint refused the conversation, which no caller
- /// chose. A host that stops closes with :
- /// the process went away underneath a live conversation, which no caller chose either. Neither is a
- /// hang-up, neither is the agent finishing, and neither is a transfer, so within a set of four
- /// the honest answer is the fault — recorded as one rather than quietly counted as a caller who
- /// decided to leave.
- ///
- ///
private static ConversationEndReason EndReasonOf(WebSocketCloseStatus status)
{
return status is WebSocketCloseStatus.NormalClosure
@@ -503,36 +253,23 @@ private static ConversationEndReason EndReasonOf(WebSocketCloseStatus status)
}
/// Logs the two faults that are the vendor's doing rather than this endpoint's defect.
- /// The exception a loop or a turn ended with.
+ /// The exception a loop or the session close ended with.
/// Which task faulted.
/// The id of the conversation, or a placeholder before setup.
///
/// when this method logged itself, and
/// to leave it to .
///
- ///
- /// The classify hook conversations before its own general clause.
- /// Both causes below name a vendor type, which is why they stay here rather than moving into
- /// that observer with the rest of the teardown machinery.
- ///
private bool ClassifyTelnyxFault(Exception fault, ConnectionTaskKind kind, string conversationId)
{
switch (fault)
{
case RelayProtocolException protocol:
- // Malformed JSON or an oversized frame: the relay broke the contract, not a defect
- // this endpoint owns. DetermineCloseStatus reads this same exception straight off
- // the task afterward for the close status, so this line only has to log it — every
- // other malformed-input line in this file (PromptBeforeSetup, FrameRefused,
- // MalformedInterruptFrame) already logs at Warning, and LogFault's generic path
- // would otherwise report this at Error, the same level as an unhandled bug.
TelnyxRelayLog.RelayProtocolViolation(_logger, conversationId, protocol.Message);
return true;
case WebSocketException { WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely }
when kind == ConnectionTaskKind.ReadLoop:
- // The conversation dropped with no close frame. Section "close statuses" of task 7: that is
- // an ordinary end of a conversation, not a fault, so it is worth a line but not an error one.
TelnyxRelayLog.ConversationDroppedWithNoCloseFrame(_logger, conversationId);
return true;
@@ -541,7 +278,7 @@ private bool ClassifyTelnyxFault(Exception fault, ConnectionTaskKind kind, strin
}
}
- /// Logs the fault of a read loop, a turn, or a write loop, whichever names.
+ /// Logs the fault of a read loop, a write loop, or the session close, whichever names.
/// Which task faulted.
/// The id of the conversation, or a placeholder before setup.
/// The cause.
@@ -556,10 +293,6 @@ private void LogFault(ConnectionTaskKind kind, string conversationId, Exception
{
switch (kind)
{
- case ConnectionTaskKind.Turn:
- TelnyxRelayLog.TurnFaulted(_logger, conversationId, fault);
- break;
-
case ConnectionTaskKind.WriteLoop:
TelnyxRelayLog.WriteLoopFaulted(_logger, conversationId, fault);
break;
@@ -580,396 +313,22 @@ private void LogFault(ConnectionTaskKind kind, string conversationId, Exception
/// Reads one inbound message with the vendor's own reader.
/// The whole message, already reassembled by the pump.
/// The frame, the name of a type this build does not model, or the name of one whose body would not bind.
- ///
- /// The parse seam of . says the
- /// same three things does, so the
- /// carries nothing the outcome does not: a frame is present exactly when that method returned
- /// .
- ///
private static FrameOutcome ParseFrame(ReadOnlySpan utf8)
{
_ = TelnyxRelayFrameReader.TryRead(utf8, out RelayFrame? frame, out string? unknownType, out string? refusedType);
return new FrameOutcome(frame, unknownType, refusedType);
}
- /// Writes one queued item as the frame to send, or drops it.
- /// What a reply queued, and the generation it was queued under.
- /// The write loop's own writer, already reset for this frame.
- ///
- /// when a whole frame was written, and to drop
- /// without sending anything.
- ///
- ///
- /// The encode seam of , which runs it immediately before
- /// its one send. The gate below stays here, on this side of that seam, because a stale item is
- /// stale in this connection's own vocabulary and in no transport's. The writer belongs to that
- /// loop and is reused for every frame of the conversation, so nothing here keeps it, flushes it, or
- /// holds what it wrote: the bytes are the loop's to send and the loop's to overwrite.
- ///
- private bool EncodeOutbound(OutboundItem item, Utf8JsonWriter writer)
- {
- // The write loop is the last gate, immediately before the one SendAsync call the pump
- // makes for this connection. ConversationTurnArbiter's own check only narrows the race — it cannot
- // stop a token already past it at the moment a barge-in raises the reply generation:
- // one already queued, one a bounded channel's backpressure was about to release into the gap
- // StopAsync's own drain just made, or one this loop had already dequeued and was about to
- // send. All three carry the generation they were queued under, the same counter that drain
- // raises, so whichever of them let a stale item this far, it stops here instead of reaching
- // the caller. The gate is expressed in generations and never in the arbiter's turn ids: an
- // item from a turn that simply ended, with a newer one already under way, is not stale, and
- // a turn id alone cannot tell the two apart — a generation only ever moves when a barge-in
- // actually stopped the audio.
- //
- // >=, not ==. _replyGeneration and every item's own Generation both only ever increase,
- // so "the highest generation a barge-in has cut off is at or above this item's own" is
- // the question that actually matches a conversation across two barge-ins: with == alone, an item of
- // generation N already dequeued here, then gated after a second, later barge-in raises
- // the mark to N+1, would compare N+1 != N, pass, and reach the caller — exactly the
- // straggler this gate exists to stop. With no interrupt at all _replyGeneration stays
- // 0, and a queued item's generation is always at least 1, so nothing is dropped that
- // this comparison would not already drop with ==. The one behaviour change is a prior
- // turn's trailing last: true frame, still queued when a later barge-in lands: it now
- // drops instead of reaching the caller, which is exactly what StopAsync's own
- // unconditional drain already does to that same frame whenever it is still queued at the
- // moment of the interrupt rather than sent moments later.
- if (item.Generation is { } generation && Interlocked.Read(ref _replyGeneration) >= generation)
- {
- return false;
- }
-
- // The same options SerializeToUtf8Bytes was given, and the same bytes: TelnyxRelayJson sets
- // no encoder and no indentation, so a default Utf8JsonWriter escapes and lays out this frame
- // exactly as the writer JsonSerializer used to build per conversation did.
- JsonSerializer.Serialize(writer, item.Frame, item.Frame.GetType(), TelnyxRelayJson.Options);
- return true;
- }
-
- private async Task DispatchAsync(RelayFrame frame)
- {
- switch (frame)
- {
- case RelayFrame.Setup setup:
- await StartConversationAsync(setup).ConfigureAwait(false);
- break;
-
- case RelayFrame.Prompt { Last: true } prompt:
- await StartTurnAsync(prompt.VoicePrompt).ConfigureAwait(false);
- break;
-
- case RelayFrame.Prompt:
- // An interim transcript. The turn starts on the final one.
- break;
-
- case RelayFrame.Interrupt interrupt:
- HandleInterrupt(interrupt);
- break;
-
- case RelayFrame.Dtmf:
- // Never the digit itself. A keypad carries card numbers, PINs, and dates of birth,
- // and the house rule in Log.cs is that no line carries what the caller said.
- TelnyxRelayLog.DtmfReceived(_logger);
- break;
-
- case RelayFrame.Error error:
- // The vendor refused a frame this endpoint sent. That is our defect.
- TelnyxRelayLog.FrameRefused(_logger, _session?.ConversationId ?? BeforeSetupConversationId, error.Description);
- break;
-
- default:
- // The reader hands back only the frames this build models; see TelnyxRelayFrameReader.TryRead.
- break;
- }
- }
-
- private async Task StartConversationAsync(RelayFrame.Setup setup)
- {
- IConversationSessions sessions = EntrySessions();
-
- // One socket carries one conversation, and the vendor sends one setup frame. A second one replaces
- // the session rather than refusing the socket, because section 7.1 forbids dropping a conversation
- // over a frame the vendor got wrong, and because the vendor's latest word about the conversation is
- // the one to answer. The first session is closed here and not left behind: teardown only
- // ever closes the session this connection currently holds, so this is the last moment
- // anything waits for the words the replaced conversation still owed store 1.
- // CancellationToken.None, because the close must still run while the connection is
- // tearing down.
- if (_session is { } replaced)
- {
- if (!_loggedSecondSetup)
- {
- _loggedSecondSetup = true;
- TelnyxRelayLog.SecondSetupFrame(_logger, replaced.ConversationId);
- }
-
- // Teardown will later close the session that replaced this one and never this one, so
- // this is the last moment anything can wait for the writes it queued. Bounded and
- // swallowed by the observer, so a store that stopped answering costs the replaced conversation
- // its last rows rather than stalling the read loop for the rest of the connection.
- // Through CloseSessionAsync, and never CloseAsync(...).AsTask(): a store that throws
- // synchronously throws before AsTask() is ever reached, so the observer would not see
- // it and §7.1's rule against dropping a conversation over a bad frame would be broken by the
- // very frame that rule is about. An async method turns that throw into the task the
- // observer bounds.
- await _observer
- .ObserveAsync(
- CloseSessionAsync(replaced.ConversationId),
- ConnectionTaskKind.SessionClose,
- _options.CloseTimeout)
- .ConfigureAwait(false);
- }
-
- // conversationSessionId groups the legs of one logical conversation, so the id survives the warm transfer
- // that slice 2 adds. A leg id would break there.
- _session = await sessions.OpenAsync(setup.ConversationSessionId, _cancellation.Token).ConfigureAwait(false);
-
- // Built on the first setup frame and never again: a later one only tells the arbiter which
- // call to answer from now on. One socket runs one turn at a time, and that promise belongs
- // to this connection rather than to any one session of it — a second arbiter would bring
- // its own _turnActive, its own held prompt, its own ids, and its own log-once flag, so a
- // prompt arriving while the replaced session's turn still ran would find nothing in flight
- // and start a second reply into the same channel. Rebinding keeps every one of those
- // exactly where the old, connection-held state kept them.
- if (_arbiter is { } arbiter)
- {
- arbiter.Rebind(_session);
- return;
- }
-
- // Not in the constructor: the arbiter runs turns against a session, and no session exists
- // until this frame arrives. The output port handed over is this connection itself — one
- // socket is both halves of this vendor's channel — and the arbiter cannot tell that from a
- // port belonging to a separate synthesizer, which is the whole of D8.
- // The two log callbacks are this vendor's own lines, bound here: the arbiter decides when a
- // prompt is held and when a further one is dropped, and names no log event of its own.
- _arbiter = new ConversationTurnArbiter(
- _session,
- this,
- _observer,
- conversationId => TelnyxRelayLog.PromptHeld(_logger, conversationId),
- conversationId => TelnyxRelayLog.PendingPromptDropped(_logger, conversationId),
- _connectionToken);
- }
-
- private async Task StartTurnAsync(string text)
- {
- if (_arbiter is not { } arbiter)
- {
- // Log once for the conversation, not once for the frame. Same rule as the unknown-frame line.
- if (!_loggedPromptBeforeSetup)
- {
- _loggedPromptBeforeSetup = true;
- TelnyxRelayLog.PromptBeforeSetup(_logger);
- }
-
- return;
- }
-
- await KeepSessionAliveAsync().ConfigureAwait(false);
-
- // Discarded, never awaited. The arbiter's own task follows the turn to its end, and the read
- // loop is what calls this: a read loop that waited for a turn could not receive the
- // interrupt frame that ends it. The turn itself stays visible on CurrentTurn, which the next
- // turn and teardown both observe.
- _ = arbiter.StartTurnAsync(text);
- }
-
- /// Tells the store this conversation is still being had.
- /// A task that completes once the store has been read.
- ///
- /// This connection opens its session once and holds it for the whole conversation, so nothing else ever
- /// reads it back. A store that expires an idle session counts a read as the one sign the conversation is
- /// still live, so without this a conversation longer than that timeout is swept out from under the turn
- /// about to run: its words are flushed, its session is dropped, and every turn after it runs on
- /// an object no close can reach. A conversation nobody speaks on is ended by this socket's own
- /// IdleTimeout instead, which is a real end and closes the chain.
- /// The result is not used. It is the read itself that matters, and the session this connection
- /// holds is the one the arbiter must keep running whatever the store answers.
- ///
- private async Task KeepSessionAliveAsync()
- {
- if (_session is not { } session)
- {
- return;
- }
-
- // §7.1 forbids dropping a conversation over one frame, and this runs on the read loop. A store that
- // throws costs this conversation its place in the sweep, never the conversation itself.
- try
- {
- _ = await EntrySessions().TryGetAsync(session.ConversationId, _connectionToken).ConfigureAwait(false);
- }
- catch (Exception fault) when (fault is not OperationCanceledException)
- {
- ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.SessionTouchFaulted(
- _logger, session.ConversationId, fault));
- }
- }
-
- private void HandleInterrupt(RelayFrame.Interrupt interrupt)
- {
- if (_arbiter is not { } arbiter || _session is not { } session)
- {
- return;
- }
-
- // Section 7.1's rule for an unknown frame type and for a prompt before setup applies here
- // too: a frame the vendor got wrong must not drop the conversation. System.Text.Json enforces
- // neither a missing property nor a negative number at run time, so a frame missing
- // utteranceUntilInterrupt, or carrying a negative durationUntilInterruptMs, would otherwise
- // reach ConversationSession.Interrupt's own ArgumentNullException or ArgumentOutOfRangeException
- // uncaught and take the read loop down with it. Nothing here clamps the duration — a clamp
- // is an estimate, and D28 exists to keep estimates out of this path. This guard stays here,
- // and never moves into the arbiter: validating these two values is reading a vendor frame.
- if (interrupt.UtteranceUntilInterrupt is null || interrupt.DurationUntilInterruptMs < 0)
- {
- if (!_loggedMalformedInterrupt)
- {
- _loggedMalformedInterrupt = true;
- TelnyxRelayLog.MalformedInterruptFrame(_logger, session.ConversationId);
- }
-
- return;
- }
-
- // The vendor measured both values itself. D28 and item 6a: nothing here estimates either
- // one.
- _ = arbiter.Interrupt(
- interrupt.UtteranceUntilInterrupt,
- TimeSpan.FromMilliseconds(interrupt.DurationUntilInterruptMs));
-
- // Logged last, once the raised id, the conversation to Interrupt, and the drain behind
- // IConversationOutputPort.StopAsync have all already happened, so this line is proof the whole
- // guard is in place — never the words the caller said or heard.
- TelnyxRelayLog.InterruptReceived(_logger, session.ConversationId);
- }
-
- ///
- public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default)
- {
- return _outbound.Writer.WriteAsync(
- new OutboundItem(CurrentGeneration() + 1, new RelayToken(fragment, Last: false)),
- cancellationToken);
- }
-
- ///
- public ValueTask CompleteAsync(CancellationToken cancellationToken = default)
- {
- // The vendor closes a reply on last: true, and the sample uses an empty final token when
- // the stream ended with no trailing text.
- return _outbound.Writer.WriteAsync(
- new OutboundItem(CurrentGeneration() + 1, new RelayToken(string.Empty, Last: true)),
- cancellationToken);
- }
-
- ///
- public ValueTask StopAsync(CancellationToken cancellationToken = default)
- {
- // Raised before the drain, and the write loop's own gate reads the same field. The relay
- // already stopped its own audio. Conversation Relay carries no clear frame, so anything
- // still queued here is the only audio left that could start it playing again, and this
- // queue is the only thing this process still controls.
- _ = Interlocked.Increment(ref _replyGeneration);
-
- while (_outbound.Reader.TryRead(out _))
- {
- // Drained, never sent: the barge-in above already cut off everything still queued.
- }
-
- return ValueTask.CompletedTask;
- }
-
- ///
- ///
- ///
- /// This stream yields nothing today, and that is deliberate rather than a defect. The
- /// read loop still drives DispatchAsync directly, so every inbound frame of this conversation
- /// already reaches the arbiter by that path; rerouting it through this stream is a later change
- /// that no current caller needs. Until then this half of the channel exists so the shape is
- /// honest — one object really does fill both slots — and it completes when the connection does,
- /// so an await foreach over it ends with the conversation rather than at once. A consumer that
- /// cancels its own read gets instead, which is the rule
- /// sets for every implementation of this port.
- ///
- ///
- /// One consumer for the life of the port, and the guard is here rather than inside the iterator
- /// below: an iterator's body does not run until something enumerates it, so a second call would
- /// otherwise hand back a stream that throws late, or never at all if nobody enumerates it.
- ///
- ///
- public IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default)
- {
- return Interlocked.CompareExchange(ref _listening, 1, 0) != 0
- ? throw new InvalidOperationException("This relay connection is already being read.")
- : ListenCoreAsync(cancellationToken);
- }
-
///
- ///
- /// Nothing to release here, and disposing a port must not tear this connection down. The
- /// channel, the linked cancellation source, and the socket all belong to RunAsync, which
- /// releases them in its own teardown once the last turn has stopped writing — and for this
- /// vendor the conversation ends when that method returns, not when whoever holds the
- /// lets go of it.
- ///
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
- return ValueTask.CompletedTask;
- }
-
- /// Yields nothing, and completes when this connection ends.
- /// Ends the stream early, ahead of the connection itself.
- /// An empty stream, deliberately. 's remarks say why.
- private async IAsyncEnumerable ListenCoreAsync(
- [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- // Linked, so the caller's own token ends the stream too, and built from the captured
- // connection token rather than from _cancellation.Token for the reason that field records.
- using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(_connectionToken, cancellationToken);
-
- try
- {
- await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Which token fired is the whole difference, and IConversationInputPort.ListenAsync settles
- // it: the connection ending is the ordinary end of the conversation and completes this stream,
- // while the consumer's own cancellation throws, as any cancelled await does. Rethrown
- // through the caller's token rather than by letting the linked one out, so the exception
- // names the token the caller actually cancelled. Both fired at once counts as
- // cancellation: the consumer asked to stop, and it gets the answer it asked for.
- cancellationToken.ThrowIfCancellationRequested();
- }
-
- yield break;
+ await _input.DisposeAsync().ConfigureAwait(false);
+ await _output.DisposeAsync().ConfigureAwait(false);
}
- /// Reads the newest reply generation a barge-in has cut off, or 0 when none has.
- /// The counter, read the same way the write loop's own gate reads it.
- private long CurrentGeneration()
+ private Task AcceptAsync(object frame)
{
- return Interlocked.Read(ref _replyGeneration);
+ return _input.AcceptAsync((RelayFrame)frame);
}
}
-
- /// The relay broke the contract, and the socket must close with a reason.
- /// The close status the vendor should see.
- /// Why the endpoint refused.
- internal sealed class RelayProtocolException(WebSocketCloseStatus status, string message)
- : Exception(message)
- {
- /// Gets the status the socket closes with.
- public WebSocketCloseStatus Status { get; } = status;
- }
-
- /// One item queued for the write loop, carrying the reply generation it was written under.
- ///
- /// The reply generation was queued under, or for a
- /// frame no reply owns — nothing writes one of those yet, but the write loop's gate only applies
- /// when a generation is present, so a future frame outside the reply lifecycle (a close handoff,
- /// for one) can opt out by carrying none. This type never reaches :
- /// only is serialized, so nothing here changes the wire.
- ///
- /// The frame to serialize and send: a today.
- internal readonly record struct OutboundItem(long? Generation, object Frame);
}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs
index c6ca5676..d84ff430 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs
@@ -1,6 +1,6 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.AspNetCore.Conversation;
+using AgentCore.AspNetCore.Voice;
using Microsoft.AspNetCore.Http;
namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
@@ -24,15 +24,6 @@ public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapt
/// The one providers.conversation.kind value this vendor answers to.
public const string TelnyxRelayKind = "telnyx-relay";
- /// The longest delay Task.Delay, CancelAfter, and Task.WaitAsync all accept.
- ///
- /// One millisecond short of — about 49.7 days — confirmed on net10
- /// for all three. The schema caps the document at 4294967 whole seconds for the same reason;
- /// this check stays because the schema cannot express the ceiling without duplicating the
- /// runtime constant.
- ///
- private static readonly TimeSpan MaximumBoundedDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
-
///
public string Kind => TelnyxRelayKind;
@@ -90,6 +81,8 @@ internal static TelnyxRelayOptions BuildOptions(ConversationProviderConfiguratio
options.CloseTimeout = ToTimeSpan("closeTimeoutSeconds", close);
}
+ options.Voice = VoiceOptionsBinder.Build(configuration);
+
return options;
}
@@ -110,12 +103,12 @@ private static TimeSpan ToTimeSpan(string field, int seconds)
}
TimeSpan value = TimeSpan.FromSeconds(seconds);
- return value <= MaximumBoundedDelay
+ return value <= BoundedTimerLimits.MaximumBoundedDelay
? value
: throw Fail(
field,
$"is {seconds} seconds, which Task.Delay, CancelAfter, and Task.WaitAsync all "
- + $"refuse at run time. The longest a timer can hold is {MaximumBoundedDelay}.");
+ + $"refuse at run time. The longest a timer can hold is {BoundedTimerLimits.MaximumBoundedDelay}.");
}
/// Builds the one failure every check here raises, pointed at the field.
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs
index ae5e047f..b4bcee8a 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs
@@ -1,34 +1,12 @@
-using AgentCore.Application.Conversation;
-using AgentCore.Application.Ports;
+using AgentCore.AspNetCore.Voice;
namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
{
- /// Opens a Telnyx relay channel, where one connection is both halves.
- ///
- ///
- /// Which of the two shapes this is, and why. A relay connection is born from an accepted
- /// WebSocket rather than from a caller, so there is no moment at which something above asks this
- /// factory to go and open a conversation: by the time a channel could exist, the socket is already up and
- /// the connection already owns it. The other shape on offer was to reach the connection through
- /// , and that context carries a conversation id and the host's own
- /// per-conversation values and no vendor field at all — which is exactly what makes D8 hold, and exactly
- /// why it cannot smuggle a connection through. So the entry point stays
- /// ,
- /// app.MapCall() is untouched, and this factory is built with the connection it hands out.
- ///
- ///
- /// Nothing wires it into the container yet, and nothing can even construct it yet: the connection
- /// it takes has a private constructor and
- /// keeps the only instance to itself. That is deliberate, not an oversight. The caller that will
- /// need a factory is the split adapter — a recognizer on one side and a synthesizer on the other —
- /// which does open its channel on request; whichever of the two arrives first, that adapter or a
- /// reason for this connection to hand itself out, is the change that gives this type its first
- /// caller. It exists now so that adapter meets an interface with a bundled implementation already
- /// behind it, and so the bundled case is written down as the ordinary one rather than the exception.
- ///
- ///
- /// The connection that accepted this conversation's socket, and that is both halves of it.
- internal sealed class TelnyxRelayConversationChannelFactory(TelnyxRelayConnection connection) : IConversationChannelFactory
+ /// Opens a Telnyx relay channel over the two halves of one connection.
+ /// The caller half of the connection that accepted this conversation's socket.
+ /// The reply half of the same connection.
+ internal sealed class TelnyxRelayConversationChannelFactory(TelnyxRelayInput input, TelnyxRelayOutput output)
+ : IConversationChannelFactory
{
///
///
@@ -41,8 +19,7 @@ public ValueTask OpenAsync(
ConversationChannelContext context,
CancellationToken cancellationToken = default)
{
- // The same object in both slots. Nothing above this line can tell, and nothing may ask.
- return ValueTask.FromResult(new ConversationChannel(connection, connection));
+ return ValueTask.FromResult(new ConversationChannel(input, output));
}
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs
index a041ac99..e9362b2b 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs
@@ -9,53 +9,16 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
///
/// Maps the Telnyx Conversation Relay socket onto the turn loop.
///
- ///
- ///
- /// This is the second inbound adapter onto , and
- /// the first one that carries a conversation. The /v1/responses endpoint is the other. Both
- /// read the same contract, so D8 holds and the core never learns a vendor frame schema.
- ///
- ///
- /// Nothing here decides whether this transport is in use. The composition root reads
- /// providers.conversation while the host starts, picks the one transport it names, and asks it for a
- /// handler through ;
- ///
- /// owns only the route string. This type maps whatever it is handed, and it is
- /// so that is the only thing that
- /// hands it anything, apart from the test host, through InternalsVisibleTo: a host names its
- /// route once and changes vendors in the document.
- ///
- ///
- /// The host owns the WebSocket middleware, and its defaults suit a browser rather than a conversation.
- /// Conversation app.UseWebSockets with a KeepAliveInterval and a KeepAliveTimeout of
- /// about 20 seconds. The shipped default is two minutes with no timeout, which lets a dead conversation
- /// hold a session for two minutes.
- ///
- ///
internal static class TelnyxRelayEndpointRouteBuilderExtensions
{
/// The route the test host maps this endpoint on.
- ///
- /// It is no longer a fallback: MapCall always supplies a pattern, so no production path
- /// reaches this value. It is kept because the relay suite maps its host through
- /// directly and needs one route string both sides agree on.
- ///
- public const string DefaultPattern = "/v1/{" + Conversation.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay";
+ public const string DefaultPattern = "/v1/{" + AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay";
/// Maps the socket on one route, with the limits the host chose.
/// The route builder of the host.
/// The route to answer on. It must carry the {entry} parameter.
/// What the endpoint may do, and for how long.
/// The mapped endpoint, so a host adds its own conventions.
- ///
- /// The options are not checked here. is what
- /// builds them out of providers.conversation, and it refuses a value Task.Delay,
- /// CancelAfter, or Task.WaitAsync would reject before that value is ever written
- /// into a — with a
- /// naming the field of
- /// the document rather than a C# property. Checking again here would only repeat that work in
- /// the wrong vocabulary.
- ///
public static IEndpointConventionBuilder MapTelnyxRelay(
this IEndpointRouteBuilder endpoints,
string pattern,
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs
new file mode 100644
index 00000000..7a854628
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs
@@ -0,0 +1,145 @@
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using AgentCore.AspNetCore.Voice;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
+{
+ ///
+ /// The caller half of one relay socket: turns each inbound frame into a .
+ ///
+ /// The logger of the connection.
+ /// Reads the id of the conversation for a log line, or a placeholder before it started.
+ internal sealed class TelnyxRelayInput(ILogger logger, Func conversationId)
+ : IConversationInputPort
+ {
+ private readonly Channel<(ConversationInput Input, TaskCompletionSource Handled)> _inputs =
+ Channel.CreateUnbounded<(ConversationInput Input, TaskCompletionSource Handled)>(
+ new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
+
+ private int _listening;
+
+ private bool _loggedMalformedInterrupt;
+
+ private bool _sawSetup;
+
+ /// Reads one frame the relay sent, in the order it sent them.
+ /// The frame the reader parsed.
+ ///
+ /// A task that completes once the consumer has handled the event the frame became, or at once when
+ /// it became none. The read loop awaits it, so a close frame cannot overtake the setup before it.
+ ///
+ public Task AcceptAsync(RelayFrame frame)
+ {
+ switch (frame)
+ {
+ case RelayFrame.Setup setup:
+ _sawSetup = true;
+ return PublishAsync(new ConversationInput.Started(setup.ConversationSessionId));
+
+ case RelayFrame.Prompt prompt:
+ return PublishAsync(new ConversationInput.Utterance(prompt.VoicePrompt, prompt.Lang, prompt.Last));
+
+ case RelayFrame.Interrupt interrupt:
+ return AcceptInterruptAsync(interrupt);
+
+ case RelayFrame.Dtmf dtmf:
+ // Never the digit itself. A keypad carries card numbers, PINs, and dates of birth,
+ // and the house rule in Log.cs is that no line carries what the caller said.
+ TelnyxRelayLog.DtmfReceived(logger);
+ return PublishAsync(new ConversationInput.Keypress(dtmf.Digit));
+
+ case RelayFrame.Error error:
+ // The vendor refused a frame this endpoint sent. That is our defect.
+ TelnyxRelayLog.FrameRefused(logger, conversationId(), error.Description);
+ return Task.CompletedTask;
+
+ default:
+ // The reader hands back only the frames this build models; see TelnyxRelayFrameReader.TryRead.
+ return Task.CompletedTask;
+ }
+ }
+
+ /// Ends the stream: the socket is gone, so the conversation is over.
+ public void Complete()
+ {
+ _ = _inputs.Writer.TryComplete();
+ }
+
+ ///
+ public IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default)
+ {
+ return Interlocked.CompareExchange(ref _listening, 1, 0) != 0
+ ? throw new InvalidOperationException("This relay connection is already being read.")
+ : ListenCoreAsync(cancellationToken);
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ private async IAsyncEnumerable ListenCoreAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach ((ConversationInput input, TaskCompletionSource handled) in _inputs.Reader
+ .ReadAllAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ yield return input;
+ }
+ finally
+ {
+ // The consumer asked for the next event, or stopped reading: either way it is done with this one.
+ handled.TrySetResult();
+ }
+ }
+ }
+ finally
+ {
+ // Nothing reads what is left, so release every read-loop wait still parked on it.
+ _ = _inputs.Writer.TryComplete();
+
+ while (_inputs.Reader.TryRead(out (ConversationInput Input, TaskCompletionSource Handled) orphan))
+ {
+ orphan.Handled.TrySetResult();
+ }
+ }
+ }
+
+ private Task AcceptInterruptAsync(RelayFrame.Interrupt interrupt)
+ {
+ if (!_sawSetup)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (interrupt.UtteranceUntilInterrupt is null || interrupt.DurationUntilInterruptMs < 0)
+ {
+ if (!_loggedMalformedInterrupt)
+ {
+ _loggedMalformedInterrupt = true;
+ TelnyxRelayLog.MalformedInterruptFrame(logger, conversationId());
+ }
+
+ return Task.CompletedTask;
+ }
+
+ return PublishAsync(new ConversationInput.Barge(
+ interrupt.UtteranceUntilInterrupt,
+ TimeSpan.FromMilliseconds(interrupt.DurationUntilInterruptMs)));
+ }
+
+ private Task PublishAsync(ConversationInput input)
+ {
+ TaskCompletionSource handled = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ return _inputs.Writer.TryWrite((input, handled)) ? handled.Task : Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs
index b1d3cd8a..e6e8b990 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs
@@ -5,17 +5,6 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
///
/// Every line the relay connection writes.
///
- ///
- ///
- /// The methods are source-generated by , so a line that nobody
- /// listens to costs one level check and allocates nothing. CA1848 asks for exactly this shape
- /// rather than a direct conversation on .
- ///
- ///
- /// No line here carries what the caller said or pressed. The audit chain of D23 is the record of a
- /// conversation, and a log is not; a keypad in particular carries card numbers, PINs, and dates of birth.
- ///
- ///
internal static partial class TelnyxRelayLog
{
/// Section 3.1. An unmodelled frame type, logged once for the conversation.
@@ -46,54 +35,20 @@ internal static partial class TelnyxRelayLog
Message = "the relay refused a frame on conversation {ConversationId}: {Description}")]
public static partial void FrameRefused(ILogger logger, string conversationId, string description);
- /// A prompt arrived before any setup frame, logged once for the conversation.
- /// The logger of the connection.
- [LoggerMessage(
- EventId = 4,
- Level = LogLevel.Warning,
- Message = "the relay sent a prompt before any setup frame. Every prompt before setup is dropped.")]
- public static partial void PromptBeforeSetup(ILogger logger);
-
/// The read loop ended in a fault rather than a clean close.
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
/// The cause.
- ///
- /// states the house rule: on a voice conversation, silence is
- /// the failure, so this line makes sure a read loop fault is never silent. It fires for anything
- /// the read loop throws other than the two causes that have their own, quieter case: a
- /// logs through at
- /// Warning instead, because it is a protocol violation this endpoint already refused and closed
- /// on, not an unhandled defect; and a dropped socket with no close frame logs through
- /// at Information. Everything else reaching this line
- /// is this endpoint's own bug.
- ///
[LoggerMessage(
EventId = 5,
Level = LogLevel.Error,
Message = "the relay read loop of conversation {ConversationId} ended in a fault.")]
public static partial void ReadLoopFaulted(ILogger logger, string conversationId, Exception exception);
- /// A turn faulted rather than finishing or being cancelled by the connection itself.
- /// The logger of the connection.
- /// The id of the conversation.
- /// The cause.
- [LoggerMessage(
- EventId = 6,
- Level = LogLevel.Error,
- Message = "a turn of conversation {ConversationId} faulted.")]
- public static partial void TurnFaulted(ILogger logger, string conversationId, Exception exception);
-
/// The write loop ended in a fault rather than a clean close.
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
/// The cause.
- ///
- /// The write loop is the only task that touches the socket, so its fault — typically a
- /// the relay caused, or an
- /// racing teardown — is the most useful of the three to
- /// see, and the one round 1 left unobserved.
- ///
[LoggerMessage(
EventId = 7,
Level = LogLevel.Error,
@@ -104,13 +59,6 @@ internal static partial class TelnyxRelayLog
/// The logger of the connection.
/// The id of the conversation.
/// Which task did not stop in time, for example "the last turn".
- ///
- /// Cancellation reaches a well-behaved task, but this line is not proof of one that ignored its
- /// token — a task that honours cancellation yet takes longer than CloseTimeout to unwind,
- /// a slow completion step or a slow sink, reaches it the same way. Left unbounded, either case
- /// would otherwise wedge teardown forever: the session would stay in the store, and the Kestrel
- /// request behind it would never complete.
- ///
[LoggerMessage(
EventId = 8,
Level = LogLevel.Error,
@@ -121,12 +69,6 @@ internal static partial class TelnyxRelayLog
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
/// The cause.
- ///
- /// Logged and swallowed rather than left to propagate, because a throw here must not skip the
- /// write-loop observation or the session close that follow it in RunAsync. A close that
- /// never runs leaves the conversation to wait out its idle timeout, and nothing waits for the words its
- /// last turn still owed store 1.
- ///
[LoggerMessage(
EventId = 9,
Level = LogLevel.Error,
@@ -137,72 +79,24 @@ internal static partial class TelnyxRelayLog
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
/// The cause.
- ///
- /// Logged and swallowed rather than left to propagate, because Cancel() runs with
- /// throwOnFirstException: false: every callback still ran and both loops are already
- /// cancelled by the time this fires, so the only thing a throw here would still cost is the
- /// rest of teardown — the write-loop observation, the close, and the session removal.
- ///
[LoggerMessage(
EventId = 10,
Level = LogLevel.Error,
Message = "cancelling the connection token of conversation {ConversationId} faulted.")]
public static partial void CancellationFaulted(ILogger logger, string conversationId, Exception exception);
- /// A third final prompt arrived while one turn ran, logged once for the conversation.
- /// The logger of the connection.
- /// The id of the conversation.
- ///
- /// Item 6a of section 11 holds one pending prompt so a caller's second sentence is not lost.
- /// A third would queue a prompt behind a question the caller has already moved past, so it is
- /// dropped, and this line says so without repeating the words dropped.
- ///
- [LoggerMessage(
- EventId = 11,
- Level = LogLevel.Information,
- Message = "a further prompt arrived on conversation {ConversationId} while one turn was already held. It is dropped.")]
- public static partial void PendingPromptDropped(ILogger logger, string conversationId);
-
- /// The relay reported a barge-in. Never the words the caller said.
- /// The logger of the connection.
- /// The id of the conversation.
- [LoggerMessage(
- EventId = 12,
- Level = LogLevel.Information,
- Message = "the relay reported a barge-in on conversation {ConversationId}.")]
- public static partial void InterruptReceived(ILogger logger, string conversationId);
-
/// A malformed interrupt frame was refused. Section 7.1: a bad frame must not drop the conversation.
/// The logger of the connection.
/// The id of the conversation.
- ///
- /// Fires when utteranceUntilInterrupt is missing or durationUntilInterruptMs is
- /// negative, logged once for the conversation the same way an unmodelled frame type is.
- ///
[LoggerMessage(
EventId = 13,
Level = LogLevel.Warning,
Message = "the relay sent a malformed interrupt frame on conversation {ConversationId}. It is dropped.")]
public static partial void MalformedInterruptFrame(ILogger logger, string conversationId);
- /// A second final prompt arrived while one turn ran, and is held for when it ends.
- /// The logger of the connection.
- /// The id of the conversation.
- [LoggerMessage(
- EventId = 14,
- Level = LogLevel.Information,
- Message = "a further prompt arrived on conversation {ConversationId} while one turn ran. It is held.")]
- public static partial void PromptHeld(ILogger logger, string conversationId);
-
/// The relay dropped the socket with no close frame. Task 7: an ordinary end of a conversation.
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
- ///
- /// Fires instead of for exactly one cause: a
- /// whose code is
- /// . The vendor
- /// never reconnects, so this is a finished conversation, not a defect this endpoint owns.
- ///
[LoggerMessage(
EventId = 15,
Level = LogLevel.Information,
@@ -216,14 +110,6 @@ internal static partial class TelnyxRelayLog
/// The fixed, vendor-free message carries — never the
/// bytes the relay actually sent.
///
- ///
- /// Malformed JSON and an oversized frame both reach here. Neither is this endpoint's own
- /// defect — the relay broke the contract, this endpoint already refused the frame, and
- /// DetermineCloseStatus already carries the right close status from the same exception —
- /// so this logs at Warning, the same level ,
- /// , and already use for a bad
- /// frame, not at the Error level uses for this endpoint's own bug.
- ///
[LoggerMessage(
EventId = 16,
Level = LogLevel.Warning,
@@ -233,12 +119,6 @@ internal static partial class TelnyxRelayLog
/// No inbound frame arrived within IdleTimeout. Task 8: an ordinary end of a conversation.
/// The logger of the connection.
/// The id of the conversation, or a placeholder before setup.
- ///
- /// The vendor never reconnects, so a silent socket is a conversation that is already over, not a fault
- /// this endpoint owns. This fires only when the idle deadline itself is the cause: the read
- /// loop checks that the connection's own teardown token has not also fired before it logs this
- /// line, so a socket closed for some other reason never gets this one instead.
- ///
[LoggerMessage(
EventId = 17,
Level = LogLevel.Information,
@@ -249,15 +129,6 @@ internal static partial class TelnyxRelayLog
/// The logger of the connection.
/// The type value whose body was refused.
/// The id of the conversation, or a placeholder before setup.
- ///
- /// Section 7.1 gives a vendor that adds a frame and a vendor that changes one the same rule:
- /// neither may drop a conversation. A decimal durationUntilInterruptMs and a non-string value in
- /// customParameters both reach here. This is the vendor's defect and not this endpoint's,
- /// so it logs at Warning, beside and
- /// , and never at the Error level a bug of ours gets. The
- /// field that would not bind is not named: a value the vendor sent may carry what the caller
- /// said.
- ///
[LoggerMessage(
EventId = 18,
Level = LogLevel.Warning,
@@ -265,42 +136,6 @@ internal static partial class TelnyxRelayLog
+ "read. It is dropped, and the conversation continues.")]
public static partial void FrameBodyRefused(ILogger logger, string frameType, string conversationId);
- /// A second setup frame arrived on one socket, logged once for the conversation.
- /// The logger of the connection.
- /// The id of the conversation the first setup frame started.
- ///
- /// One socket carries one conversation, and the vendor sends one setup. A second one is the
- /// vendor's defect, and section 7.1 forbids dropping a conversation over one, so the frame replaces the
- /// session rather than refusing the socket. The first session is closed as it goes, because
- /// teardown only ever closes the session the connection currently holds — so this is the last
- /// moment anything waits for the words the replaced conversation still owed store 1.
- ///
- [LoggerMessage(
- EventId = 19,
- Level = LogLevel.Warning,
- Message = "the relay sent a second setup frame on conversation {ConversationId}. The first session is "
- + "released and the new one replaces it.")]
- public static partial void SecondSetupFrame(ILogger logger, string conversationId);
-
- /// Closing the audit chain of a conversation faulted, so that call has no conversation.ended event.
- /// The logger of the connection.
- /// The id of the conversation whose chain did not close.
- /// The cause.
- ///
- /// This is worth an Error line and not a quieter one. §9 makes the chain of D23 the only
- /// long-term record of a conversation, and §11 item 6 says every chain ends in conversation.ended, so a
- /// chain that stops without one is a permanent gap in that record rather than a passing
- /// inconvenience. Logged and swallowed all the same: §7.1 forbids teardown throwing out of the
- /// request handler, and the session close that follows it in RunAsync must still run — a
- /// close that never runs leaves the conversation to wait out its idle timeout with its last words never
- /// waited for.
- ///
- [LoggerMessage(
- EventId = 20,
- Level = LogLevel.Error,
- Message = "the audit chain of conversation {ConversationId} could not be closed, so it has no conversation.ended event.")]
- public static partial void ConversationEndFaulted(ILogger logger, string conversationId, Exception exception);
-
/// The session of a conversation could not be closed at the end of that conversation.
/// The logger.
/// The id of the conversation.
@@ -310,15 +145,5 @@ internal static partial class TelnyxRelayLog
Level = LogLevel.Error,
Message = "the session of conversation {ConversationId} could not be closed, so its last words may never reach store 1 and it waits out the idle timeout.")]
public static partial void ConversationCloseFaulted(ILogger logger, string conversationId, Exception exception);
-
- /// The store could not be told that a conversation is still being had.
- /// The logger.
- /// The id of the conversation.
- /// The cause.
- [LoggerMessage(
- EventId = 23,
- Level = LogLevel.Warning,
- Message = "the store could not be read for conversation {ConversationId}, so that call may be dropped by the idle sweep while it is still running.")]
- public static partial void SessionTouchFaulted(ILogger logger, string conversationId, Exception exception);
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs
index 7927d71e..63240a0a 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs
@@ -1,45 +1,22 @@
+using AgentCore.AspNetCore.Voice;
+
namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
{
///
/// What the relay endpoint may do, and for how long.
///
- ///
- /// Every default here is chosen for a phone conversation. A conversation is short, it is quiet in bytes, and a
- /// dead one must be noticed in seconds rather than minutes.
- ///
internal sealed class TelnyxRelayOptions
{
/// Gets or sets the largest inbound frame the endpoint accepts, in bytes.
- ///
- /// The WebSocket middleware turns the request timeout off once it accepts, so nothing else
- /// bounds a frame. A real setup frame is under 2 KB, and 64 KB leaves room for custom
- /// parameters without letting one message exhaust the host.
- ///
public int MaxFrameBytes { get; set; } = 64 * 1024;
/// Gets or sets how long teardown gives a stuck task before it moves on.
- ///
- /// Every wait that could otherwise wedge a connection shares this one bound: how long the last
- /// turn of a conversation may take to unwind once teardown cancels it, how long the close handshake may
- /// take before the socket is aborted, and how long the words of a conversation may take to reach store 1
- /// before its session is dropped. A telephony conversation that already dropped never answers a close
- /// frame, a chat client is not guaranteed to honour cancellation promptly, and a transcript
- /// store talks to a database, so each wait needs a bound or any one of them could wedge the
- /// connection forever. Because they run one after another rather than together, one wedged
- /// connection can spend this value several times over — longer than Kestrel's own five-second
- /// default shutdown timeout. That trade is intentional: a slow, bounded teardown beats one with
- /// no bound at all.
- ///
public TimeSpan CloseTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// Gets or sets how long the endpoint waits with no inbound frame before it ends the conversation.
- ///
- /// The vendor never reconnects, so a silent socket is a conversation that is already over. The clock
- /// is a , resolved from the request's own service provider rather
- /// than a property of this class:
- /// when the host bound one, otherwise . A test that wants a
- /// deterministic idle deadline binds that same property to a fake clock.
- ///
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(5);
+
+ /// Gets or sets the away prompt and the per-tool filler this conversation's voice loop runs.
+ public VoiceOptions Voice { get; set; } = VoiceOptions.Default;
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs
new file mode 100644
index 00000000..e2613741
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs
@@ -0,0 +1,161 @@
+using System.Text.Json;
+using System.Threading.Channels;
+using AgentCore.AspNetCore.Voice;
+
+namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
+{
+ ///
+ /// The reply half of one relay socket: queues text frames for the write loop, and drops every
+ /// frame a barge-in has cut off.
+ ///
+ internal sealed class TelnyxRelayOutput : IConversationOutputPort
+ {
+ // Bounded, so a slow relay slows the turn loop instead of growing a queue without a bound.
+ private readonly Channel _outbound = Channel.CreateBounded(new BoundedChannelOptions(256)
+ {
+ FullMode = BoundedChannelFullMode.Wait,
+ SingleReader = true,
+ SingleWriter = false,
+ });
+
+ private long _replyGeneration;
+
+ // The generation of the reply begun last, fixed when it began. Read and written under _gate.
+ private long _openGeneration;
+
+ // Whether a fragment was queued since the last close or stop. Read and written under _gate.
+ private bool _replyOpen;
+
+ private readonly Lock _gate = new();
+
+ /// Gets what the write loop reads.
+ public ChannelReader Reader => _outbound.Reader;
+
+ /// Gets the lock cuts a reply off under.
+ public Lock SendGate => _gate;
+
+ /// Ends the queue: the write loop ends once it has sent what is left, and every later write is dropped.
+ /// A write already waiting for room in the full queue returns at once, rather than wait for a write loop that is gone.
+ public void Complete()
+ {
+ _ = _outbound.Writer.TryComplete();
+ }
+
+ /// Writes one queued item as the frame to send, or drops it.
+ /// What a reply queued, and the generation it was queued under.
+ /// The write loop's own writer, already reset for this frame.
+ ///
+ /// when a whole frame was written, and to drop
+ /// without sending anything.
+ ///
+ public bool Encode(OutboundItem item, Utf8JsonWriter writer)
+ {
+ if (item.Generation is { } generation && Interlocked.Read(ref _replyGeneration) >= generation)
+ {
+ return false;
+ }
+
+ JsonSerializer.Serialize(writer, item.Frame, item.Frame.GetType(), TelnyxRelayJson.Options);
+
+ return true;
+ }
+
+ ///
+ public void BeginReply()
+ {
+ lock (_gate)
+ {
+ _openGeneration = CurrentGeneration() + 1;
+ _replyOpen = false;
+ }
+ }
+
+ ///
+ public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default)
+ {
+ long generation;
+ lock (_gate)
+ {
+ generation = _openGeneration;
+ if (generation <= CurrentGeneration())
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ _replyOpen = true;
+ }
+
+ return WriteAsync(new OutboundItem(generation, new RelayToken(fragment, Last: false)), cancellationToken);
+ }
+
+ ///
+ public ValueTask CompleteAsync(CancellationToken cancellationToken = default)
+ {
+ // A reply a barge-in stopped has nothing left to close: the speech behind it still flushes as
+ // it tears down, and that closing token would reach the vendor after the interrupt.
+ long generation;
+ lock (_gate)
+ {
+ if (!_replyOpen)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ _replyOpen = false;
+ generation = _openGeneration;
+ }
+
+ // The vendor closes a reply on last: true, and the sample uses an empty final token when
+ // the stream ended with no trailing text.
+ return WriteAsync(new OutboundItem(generation, new RelayToken(string.Empty, Last: true)), cancellationToken);
+ }
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _replyOpen = false;
+ _ = Interlocked.Increment(ref _replyGeneration);
+ }
+
+ while (_outbound.Reader.TryRead(out _))
+ {
+ // Drained, never sent: the barge-in above already cut off everything still queued.
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ private static async ValueTask DropWhenCompletedAsync(ValueTask write)
+ {
+ try
+ {
+ await write.ConfigureAwait(false);
+ }
+ catch (ChannelClosedException)
+ {
+ // The socket is gone: nothing more can be sent, and nothing is waiting to hear it.
+ }
+ }
+
+ private ValueTask WriteAsync(OutboundItem item, CancellationToken cancellationToken)
+ {
+ ValueTask write = _outbound.Writer.WriteAsync(item, cancellationToken);
+ return write.IsCompletedSuccessfully ? write : DropWhenCompletedAsync(write);
+ }
+
+ /// Reads the newest reply generation a barge-in has cut off, or 0 when none has.
+ /// The counter, read the same way the write loop's own gate reads it.
+ private long CurrentGeneration()
+ {
+ return Interlocked.Read(ref _replyGeneration);
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs
index 76a03f81..65282c6b 100644
--- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs
+++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs
@@ -1,27 +1,10 @@
-using AgentCore.Application.Ports;
+using AgentCore.AspNetCore.Voice;
namespace AgentCore.AspNetCore.Vendors.TelnyxRelay
{
///
/// The selection face of the one speech vendor this solution bundles.
///
- ///
- ///
- /// D28 buys the whole speech layer — speech to text, turn detection, text to speech, and
- /// interruption — inside Telnyx Conversation Relay, so this vendor is a transport rather than a
- /// service the process dials. A host registers this through
- /// AgentCoreOptions.UseSpeech, and a document that names it under providers.speech.stt
- /// and providers.speech.tts then selects it — both roles, because it sells both. The socket
- /// itself belongs to providers.conversation, which app.MapCall() reads — and because this
- /// vendor bundles the pipe with the ears and the mouth, all three names must agree or the start
- /// fails.
- ///
- ///
- /// It carries a kind and nothing else, because does. A relay conversation
- /// is born from a socket the host already accepted, so there is nothing here to create at selection
- /// time: the frames arriving on that socket are the channel.
- ///
- ///
public sealed class TelnyxRelaySpeechAdapter : ISpeechAdapter
{
/// The one kind value, under either speech role, this vendor answers to.
diff --git a/src/AgentCore.AspNetCore/Voice/AgentState.cs b/src/AgentCore.AspNetCore/Voice/AgentState.cs
new file mode 100644
index 00000000..a1d555af
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/AgentState.cs
@@ -0,0 +1,25 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324,
+// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc.
+// Licensed under the Apache License, Version 2.0. Modified: translated to C#.
+
+namespace AgentCore.AspNetCore.Voice
+{
+ /// What the agent is doing right now.
+ internal enum AgentState
+ {
+ /// The session has not started yet.
+ Initializing,
+
+ /// Started, nothing running: no reply, no tool call, no queued speech.
+ Idle,
+
+ /// Ready for the caller, nothing queued to say.
+ Listening,
+
+ /// A model step or a tool call is running; nothing has reached the output yet.
+ Thinking,
+
+ /// Forwarding words to the output port.
+ Speaking,
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs b/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs
new file mode 100644
index 00000000..b1913929
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs
@@ -0,0 +1,13 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324
+// (AgentStateChangedEvent), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc.
+// Licensed under the Apache License, Version 2.0. Modified: translated to C#; raised as a plain event,
+// not a session-wide pub/sub topic.
+
+namespace AgentCore.AspNetCore.Voice
+{
+ /// Raised each time 's agent state actually changes.
+ /// The state before the change.
+ /// The state after the change.
+ /// When the change happened.
+ internal sealed record AgentStateChanged(AgentState OldState, AgentState NewState, DateTimeOffset CreatedAt);
+}
diff --git a/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs b/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs
new file mode 100644
index 00000000..74a3ba61
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs
@@ -0,0 +1,65 @@
+namespace AgentCore.AspNetCore.Voice
+{
+ ///
+ /// An async gate: waiters block until opens it, and closes it
+ /// again for the next round. The .NET stand-in for Python's asyncio.Event, which LiveKit uses
+ /// for its speech authorization gate and its scheduling and filler wake-ups.
+ ///
+ internal sealed class AsyncEvent
+ {
+ private readonly Lock _gate = new();
+
+ private TaskCompletionSource _source = NewSource();
+
+ /// Gets whether the event is set right now.
+ public bool IsSet
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _source.Task.IsCompleted;
+ }
+ }
+ }
+
+ /// Opens the gate. Every waiter, current or future until the next , completes.
+ public void Set()
+ {
+ lock (_gate)
+ {
+ _source.TrySetResult();
+ }
+ }
+
+ /// Closes the gate for the next round. Waiters already released by a prior keep running.
+ public void Clear()
+ {
+ lock (_gate)
+ {
+ if (_source.Task.IsCompleted)
+ {
+ _source = NewSource();
+ }
+ }
+ }
+
+ /// Waits until the event is set.
+ /// Cancels the wait. The event's own state is untouched.
+ public Task WaitAsync(CancellationToken cancellationToken = default)
+ {
+ Task task;
+ lock (_gate)
+ {
+ task = _source.Task;
+ }
+
+ return task.WaitAsync(cancellationToken);
+ }
+
+ private static TaskCompletionSource NewSource()
+ {
+ return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs b/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs
new file mode 100644
index 00000000..7c4b0324
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs
@@ -0,0 +1,49 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py
+// (_background_speeches, _interrupt_background_speeches), commit
+// d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc.
+// Licensed under the Apache License, Version 2.0. Modified: translated to C#.
+
+namespace AgentCore.AspNetCore.Voice
+{
+ /// The speeches that finished speaking but still wait on their own tools (plan 2.1).
+ /// Not thread-safe. touches it only under the session lock.
+ internal sealed class BackgroundSpeeches
+ {
+ private readonly HashSet _speeches = [];
+
+ /// Gets whether any speech is waiting on its own tools.
+ public bool Any => _speeches.Count > 0;
+
+ /// Marks a speech as waiting for its tools after its words were spoken.
+ /// The speech.
+ public void Add(SpeechHandle speech)
+ {
+ _ = _speeches.Add(speech);
+ }
+
+ /// Removes a speech added, once its tools finished.
+ /// The speech.
+ public void Remove(SpeechHandle speech)
+ {
+ _ = _speeches.Remove(speech);
+ }
+
+ /// Interrupts every speech that allows it, or all of them with .
+ /// Interrupt even speeches that disallow interruptions.
+ /// Why.
+ /// The speeches interrupted.
+ public List InterruptAll(bool force, InterruptionSource source)
+ {
+ List interrupted = [];
+ foreach (SpeechHandle speech in _speeches)
+ {
+ if (force || speech.AllowInterruptions)
+ {
+ interrupted.Add(speech.Interrupt(force, source));
+ }
+ }
+
+ return interrupted;
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs b/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs
new file mode 100644
index 00000000..2c9d8bfb
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.AspNetCore.Voice
+{
+ /// The longest span any -backed wait in this assembly accepts.
+ internal static class BoundedTimerLimits
+ {
+ ///
+ /// One millisecond short of — about 49.7 days — confirmed on net10 for
+ /// Task.Delay, CancelAfter, and Task.WaitAsync alike. A document value above
+ /// this is refused at load, where the error still carries a pointer into the document, rather than
+ /// left to throw from inside a running conversation.
+ ///
+ public static readonly TimeSpan MaximumBoundedDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs b/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs
similarity index 78%
rename from src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs
rename to src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs
index 5b5cba38..8547b4c6 100644
--- a/src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs
@@ -1,4 +1,4 @@
-namespace AgentCore.AspNetCore.Conversation
+namespace AgentCore.AspNetCore.Voice
{
/// Which task an observer is watching, so it logs the right line.
internal enum ConnectionTaskKind
@@ -6,9 +6,6 @@ internal enum ConnectionTaskKind
/// The loop that reads the transport.
ReadLoop,
- /// The task running one turn.
- Turn,
-
/// The loop that writes the transport.
WriteLoop,
@@ -17,7 +14,7 @@ internal enum ConnectionTaskKind
}
///
- /// Awaits the loops and turns of one connection, and never lets a fault go unobserved.
+ /// Awaits the loops and the session close of one connection, and never lets a fault go unobserved.
///
/// Names the conversation in a log line. A function, because the id arrives mid-conversation.
/// Logs that a task passed its teardown deadline.
@@ -33,19 +30,14 @@ internal sealed class ConnectionTaskObserver(
Action logFault,
Func classify)
{
- /// Awaits a loop or a turn task, and never lets its fault go unobserved.
- /// The read loop's task, the write loop's task, or a turn's task.
+ /// Awaits a loop or the session close, and never lets its fault go unobserved.
+ /// The read loop's task, the write loop's task, or the session close.
/// Which log line names the fault, if there is one.
///
/// How long to wait before giving up on , or to
/// wait for it unconditionally.
///
/// A task that completes once has been observed.
- ///
- /// A cancellation raised by the connection's own token is teardown that connection asked for
- /// itself, so it stays quiet. Anything else is the silence a voice conversation must never answer with,
- /// per the house rule in .
- ///
public async Task ObserveAsync(Task task, ConnectionTaskKind kind, TimeSpan? timeout = null)
{
try
@@ -101,13 +93,6 @@ public async Task ObserveAsync(Task task, ConnectionTaskKind kind, TimeSpan? tim
/// Runs one log conversation, and never lets it break the teardown sequence around it.
/// The conversation to make, already bound to its logger and its arguments.
- ///
- /// aggregates and rethrows a provider's own fault, so
- /// even a line whose only job is to report a defect can itself throw. Every log conversation teardown
- /// makes — cancelling the token, the close, an observed fault, a teardown timeout — sits behind
- /// this guard for that reason; logging exists to help diagnose a defect, and must never become a
- /// second one that stops the store removal or the rest of teardown from running.
- ///
public static void SafeLog(Action log)
{
try
@@ -128,7 +113,6 @@ private static string DisplayName(ConnectionTaskKind kind)
return kind switch
{
ConnectionTaskKind.ReadLoop => "the read loop",
- ConnectionTaskKind.Turn => "the last turn",
ConnectionTaskKind.WriteLoop => "the write loop",
ConnectionTaskKind.SessionClose => "the session close",
_ => "a task",
@@ -138,12 +122,6 @@ private static string DisplayName(ConnectionTaskKind kind)
/// Hands a fault to logFault, whichever names.
/// Which task faulted.
/// The cause, or when none was available.
- ///
- /// Called from directly, and from the fault-only continuation it
- /// attaches on a timeout. The continuation runs with nothing above it to catch a throw, so the
- /// guard here is what keeps this method from ever throwing, not the
- /// caller.
- ///
private void LogFault(ConnectionTaskKind kind, Exception? fault)
{
if (fault is null)
@@ -162,11 +140,6 @@ private void LogFault(ConnectionTaskKind kind, Exception? fault)
/// when the adapter handled itself, and
/// when it did not — including when it threw.
///
- ///
- /// An adapter's classifier is another adapter's code running inside this connection's teardown,
- /// and teardown must finish whatever that code does. A classifier that throws has handled
- /// nothing, so the fault falls through to logFault exactly as an unrecognised one does.
- ///
private bool SafeClassify(Exception fault, ConnectionTaskKind kind, string id)
{
try
diff --git a/src/AgentCore.Application/Conversation/ConversationChannel.cs b/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs
similarity index 91%
rename from src/AgentCore.Application/Conversation/ConversationChannel.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationChannel.cs
index 9eb59ade..2c8167a9 100644
--- a/src/AgentCore.Application/Conversation/ConversationChannel.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs
@@ -1,6 +1,4 @@
-using AgentCore.Application.Ports;
-
-namespace AgentCore.Application.Conversation
+namespace AgentCore.AspNetCore.Voice
{
///
/// The two halves of one conversation, opened together and disposed together.
@@ -55,10 +53,6 @@ public sealed record ConversationChannel(IConversationInputPort Input, IConversa
private bool _disposed;
/// Disposes both halves, and each underlying object exactly once.
- ///
- /// Reference equality is the test, because one object filling both slots is the ordinary case
- /// and not the exception. Disposing it twice would let an adapter's own teardown run twice.
- ///
public async ValueTask DisposeAsync()
{
if (_disposed)
diff --git a/src/AgentCore.Application/Conversation/ConversationChannelContext.cs b/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs
similarity index 64%
rename from src/AgentCore.Application/Conversation/ConversationChannelContext.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs
index 0b109aae..0b5467b1 100644
--- a/src/AgentCore.Application/Conversation/ConversationChannelContext.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs
@@ -1,11 +1,7 @@
-namespace AgentCore.Application.Conversation
+namespace AgentCore.AspNetCore.Voice
{
/// What one conversation needs to open its channel.
/// The AgentCore conversation id, which the channel must not invent for itself.
/// Per-conversation values the host attached, or .
- ///
- /// This carries no vendor field and no wire frame, so D8 holds: a factory learns what the conversation is
- /// without the core learning what the vendor is.
- ///
public sealed record ConversationChannelContext(string ConversationId, IReadOnlyDictionary? CustomParameters);
}
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs
similarity index 93%
rename from src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs
index 666aeca1..dd035679 100644
--- a/src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs
@@ -8,7 +8,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
-namespace AgentCore.AspNetCore.Conversation
+namespace AgentCore.AspNetCore.Voice
{
///
/// Maps the inbound conversation route, with the URL naming the entry, onto whichever transport the
@@ -64,9 +64,6 @@ public static string EntryOf(HttpContext http)
private static Task DispatchAsync(HttpContext http, string pattern)
{
- // GetService and never GetRequiredService, on purpose. A host may map this route with no
- // AgentCore registration at all, and such a host must get a readable reason rather than a
- // resolution failure.
if (http.RequestServices.GetService() is not { } boot)
{
return NotRoutedAsync(http, pattern, "this host registered no AgentCore services");
@@ -79,7 +76,7 @@ private static Task DispatchAsync(HttpContext http, string pattern)
string entry = EntryOf(http);
- return boot.Entries.ConversationSessions.ContainsKey(entry)
+ return boot.Entries.Entries.Contains(entry, StringComparer.Ordinal)
? handler(http)
: UnknownEntryAsync(http, EntryRegistry.UnknownEntryMessage(entry, boot.Entries.Entries));
}
diff --git a/src/AgentCore.Application/Conversation/ConversationInput.cs b/src/AgentCore.AspNetCore/Voice/ConversationInput.cs
similarity index 74%
rename from src/AgentCore.Application/Conversation/ConversationInput.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationInput.cs
index 11cec98f..b3218e95 100644
--- a/src/AgentCore.Application/Conversation/ConversationInput.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationInput.cs
@@ -1,25 +1,20 @@
-namespace AgentCore.Application.Conversation
+namespace AgentCore.AspNetCore.Voice
{
///
/// One thing the caller did, in the order it happened.
///
- ///
- ///
- /// One ordered stream carries every inbound event, and the kind is a case of this hierarchy
- /// rather than a separate stream for each. Section 3.2 asks for exactly that shape, and
- /// RelayFrame in the Telnyx adapter already reads this way.
- ///
- ///
- /// The constructor is , so the set is closed: nothing outside
- /// this assembly can add a case, and a consumer's switch over the three below is exhaustive.
- ///
- ///
public abstract record ConversationInput
{
private protected ConversationInput()
{
}
+ /// The conversation began. It comes first, before anything the caller did.
+ ///
+ /// The id the transport names this conversation by. A second one on the same stream replaces the first.
+ ///
+ public sealed record Started(string ConversationId) : ConversationInput;
+
/// The caller spoke.
/// What the recognizer heard.
/// The language it reported, for example en.
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs b/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs
similarity index 84%
rename from src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs
index cd19fbb6..2f3b1fcb 100644
--- a/src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs
@@ -1,13 +1,8 @@
using Microsoft.Extensions.Logging;
-namespace AgentCore.AspNetCore.Conversation
+namespace AgentCore.AspNetCore.Voice
{
/// The lines the conversation route writes while the host starts.
- ///
- /// Source-generated by , as CA1848 asks. Silently leaving a
- /// route unmapped is how a deployment loses every call to a 404 with nothing to read, so each
- /// reason says so once.
- ///
internal static partial class ConversationRouteLog
{
/// The host registered no conversation adapter, or loaded no document.
diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs b/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs
similarity index 90%
rename from src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationSeams.cs
index 24e5ae0b..4f1ac14b 100644
--- a/src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs
@@ -1,6 +1,6 @@
using AgentCore.Application.Providers;
-namespace AgentCore.AspNetCore.Conversation
+namespace AgentCore.AspNetCore.Voice
{
/// The names the conversation seam uses in every failure it raises.
internal static class ConversationSeams
diff --git a/src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs b/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs
similarity index 72%
rename from src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs
rename to src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs
index ac62b72c..5864e89e 100644
--- a/src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs
+++ b/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs
@@ -1,34 +1,11 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Ports;
-namespace AgentCore.Application.Conversation
+namespace AgentCore.AspNetCore.Voice
{
///
/// Refuses a document whose conversation transport and speech vendors cannot coexist.
///
- ///
- ///
- /// A transport whose frames already carry text is the recognizer and the synthesizer. A
- /// document naming it for the pipe and something else for the ears or the mouth describes a
- /// deployment that cannot exist, and the failure it would otherwise produce — a conversation that connects
- /// and never transcribes — is among the most expensive kinds to diagnose.
- ///
- ///
- /// The two roles are checked one at a time. A document that gets both wrong is wrong twice, and
- /// hearing about one of them, fixing it, and starting again to hear about the other is the slow way
- /// to learn that.
- ///
- ///
- /// No vendor name appears here. is declared by the
- /// adapter, so a second bundled vendor is one new adapter and no edit to this file. A guard that
- /// grew an || per vendor is the design this one exists to avoid.
- ///
- ///
- /// It runs from AddAgentCoreAsync rather than from the route extension, so a host that
- /// forgets to map a route still learns its document is self-contradictory.
- ///
- ///
public static class ConversationSpeechPairing
{
/// Checks that both speech roles are compatible with the selected conversation transport.
diff --git a/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs b/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs
new file mode 100644
index 00000000..71cfc851
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs
@@ -0,0 +1,302 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py (perform_llm_inference,
+// _llm_inference_task: the text and function channels one generation feeds),
+// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc.
+// Licensed under the Apache License, Version 2.0. Modified: translated to C#; the engine behind
+// IConversationPort runs the model and the tools, so the steps are read off its one stream (plan 2.1).
+
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.AspNetCore.Voice
+{
+ /// Reads one engine turn and splits it into steps at its tool contents.
+ internal sealed class EngineReplyStream
+ {
+ private readonly Channel _events = Channel.CreateUnbounded();
+
+ private readonly Lock _gate = new();
+
+ private readonly SpeechHandle _speechHandle;
+
+ private readonly TurnMetrics _metrics;
+
+ private readonly ILogger _logger;
+
+ private volatile Exception? _fault;
+
+ private int? _turnIndex;
+
+ private TurnCut? _cut;
+
+ private EngineReplyStream(IConversationPort port, SpeechHandle speechHandle, TurnMetrics metrics, ILogger logger)
+ {
+ Port = port;
+ _speechHandle = speechHandle;
+ _metrics = metrics;
+ _logger = logger;
+ }
+
+ /// Gets the conversation the turn runs on.
+ public IConversationPort Port { get; }
+
+ /// Gets a task that completes once the engine turn has ended, however it ended. It never faults.
+ public Task Completion { get; private set; } = Task.CompletedTask;
+
+ /// Gets the fault the engine turn ended with, or . Read it after .
+ public Exception? Fault => _fault;
+
+ /// Starts one engine turn once has ended, and reads it.
+ /// The conversation the turn runs on.
+ /// What the caller said.
+ /// The of the engine turn before, which must end first.
+ /// The speech this turn answers. No reading is taken once it is interrupted.
+ /// Where each step's time to first token goes.
+ /// Where an engine fault is reported.
+ /// Cancels the engine turn. Never the speech's own token.
+ /// The stream, already reading.
+ public static EngineReplyStream Start(
+ IConversationPort port,
+ string userInput,
+ Task previousRun,
+ SpeechHandle speechHandle,
+ TurnMetrics metrics,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ EngineReplyStream stream = new(port, speechHandle, metrics, logger);
+ stream.Completion = Task.Run(
+ () => stream.RunAsync(userInput, previousRun, cancellationToken), CancellationToken.None);
+ return stream;
+ }
+
+ /// Cuts this reply's engine turn where the caller stopped hearing it. Only the first cut counts.
+ /// What the caller heard.
+ ///
+ /// when the turn has started, so the engine holds this cut or an earlier one;
+ /// when the turn is still waiting on the one before it, and takes the cut the
+ /// moment it starts.
+ ///
+ public bool Cut(TurnCut cut)
+ {
+ lock (_gate)
+ {
+ if (_cut is not null)
+ {
+ return _turnIndex is not null;
+ }
+
+ _cut = cut;
+
+ // A report of what was heard can land after the next turn started. The engine then refuses the cut,
+ // since that turn's model may already have read this reply, and the history stays as it was.
+ if (_turnIndex is { } turnIndex && !Port.Cut(turnIndex, cut))
+ {
+ VoiceConversationLog.CutRefused(_logger, turnIndex);
+ }
+
+ return _turnIndex is not null;
+ }
+ }
+
+ /// Replaces the cut this reply's engine turn already took with a later account of what was heard.
+ /// What the caller heard, as the transport reported it.
+ /// Does nothing for a turn never cut. A turn not started yet takes the recut in place of its cut when it starts.
+ public void Recut(TurnCut cut)
+ {
+ lock (_gate)
+ {
+ if (_cut is null)
+ {
+ return;
+ }
+
+ _cut = cut;
+ if (_turnIndex is { } turnIndex && !Port.Recut(turnIndex, cut))
+ {
+ VoiceConversationLog.RecutRefused(_logger, turnIndex);
+ }
+ }
+ }
+
+ /// Waits until an event is ready to read or the turn has ended.
+ /// Cancels this wait only.
+ /// once the turn has ended and every event was read.
+ public ValueTask WaitToReadAsync(CancellationToken cancellationToken)
+ {
+ return _events.Reader.WaitToReadAsync(cancellationToken);
+ }
+
+ /// Reads the next event without taking it.
+ /// The next event.
+ /// when no event is ready.
+ public bool TryPeek(out ReplyEvent next)
+ {
+ return _events.Reader.TryPeek(out next);
+ }
+
+ /// Yields the current step's text, and stops before the step's first tool call or at the turn's end.
+ /// Stops the enumeration.
+ public async IAsyncEnumerable ReadStepTextAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ while (await _events.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (!_events.Reader.TryPeek(out ReplyEvent next) || next.Kind != ReplyEventKind.Text)
+ {
+ yield break;
+ }
+
+ _ = _events.Reader.TryRead(out _);
+ yield return next.Value;
+ }
+ }
+
+ /// Reads the tool events of the current round until its last call is answered.
+ /// Sees each tool call and tool result of the round, in stream order.
+ /// Cancels this wait.
+ /// when the round closed, when the turn ended first.
+ public async Task WaitForRoundAsync(Action onToolEvent, CancellationToken cancellationToken)
+ {
+ while (await _events.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (!_events.Reader.TryRead(out ReplyEvent next))
+ {
+ continue;
+ }
+
+ if (next.Kind == ReplyEventKind.RoundDone)
+ {
+ return true;
+ }
+
+ if (next.Kind is ReplyEventKind.ToolCall or ReplyEventKind.ToolResult)
+ {
+ onToolEvent(next);
+ }
+ }
+
+ return false;
+ }
+
+ private async Task RunAsync(string userInput, Task previousRun, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await previousRun.ConfigureAwait(false);
+ await using TurnRun run = await Port
+ .StartTurnAsync(new ChatMessage(ChatRole.User, userInput), origin: null, cancellationToken)
+ .ConfigureAwait(false);
+
+ lock (_gate)
+ {
+ _turnIndex = run.TurnIndex;
+
+ // A speech cut before its turn could start: the turn still runs, so the caller's words are kept.
+ if (_cut is { } pending)
+ {
+ _ = Port.Cut(run.TurnIndex, pending);
+ }
+ }
+
+ await PumpAsync(run).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Cancelled by its owner, which is not a fault, as LiveKit's _on_llm_task_done skips a cancelled task.
+ }
+ catch (Exception ex)
+ {
+ _fault = ex;
+ VoiceConversationLog.SpeechTaskFaulted(_logger, ex);
+ }
+ finally
+ {
+ _ = _events.Writer.TryComplete();
+ }
+ }
+
+ private async Task PumpAsync(TurnRun run)
+ {
+ HashSet openCalls = [];
+ List heldText = [];
+ _metrics.StartStep();
+ await foreach (ChatResponseUpdate update in run.Updates.ConfigureAwait(false))
+ {
+ foreach (AIContent content in update.Contents)
+ {
+ Read(content, openCalls, heldText);
+ }
+ }
+
+ // A call whose tool threw out of the run never gets a result, so its round never closes. The text
+ // after it is the fallback line, and the caller must hear it, unless a cut already ended the speech.
+ if (heldText.Count > 0 && !_speechHandle.IsInterrupted)
+ {
+ CloseRound(heldText);
+ }
+ }
+
+ private void Read(AIContent content, HashSet openCalls, List heldText)
+ {
+ switch (content)
+ {
+ case TextContent { Text.Length: > 0 } text when openCalls.Count > 0:
+ // Text the model wrote after a tool call belongs to the step that follows the round.
+ heldText.Add(text.Text);
+ break;
+
+ case TextContent { Text.Length: > 0 } text:
+ MarkFirstContent();
+ Write(ReplyEventKind.Text, text.Text);
+ break;
+
+ case FunctionCallContent call:
+ MarkFirstContent();
+ _ = openCalls.Add(call.CallId);
+ Write(ReplyEventKind.ToolCall, call.CallId, call.Name);
+ break;
+
+ case FunctionResultContent result when openCalls.Remove(result.CallId):
+ Write(ReplyEventKind.ToolResult, result.CallId);
+ if (openCalls.Count == 0)
+ {
+ CloseRound(heldText);
+ }
+
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ private void CloseRound(List heldText)
+ {
+ Write(ReplyEventKind.RoundDone, string.Empty);
+ _metrics.StartStep();
+ foreach (string held in heldText)
+ {
+ MarkFirstContent();
+ Write(ReplyEventKind.Text, held);
+ }
+
+ heldText.Clear();
+ }
+
+ private void MarkFirstContent()
+ {
+ if (!_speechHandle.IsInterrupted)
+ {
+ _metrics.MarkFirstContent();
+ }
+ }
+
+ private void Write(ReplyEventKind kind, string value, string? toolName = null)
+ {
+ _ = _events.Writer.TryWrite(new ReplyEvent(kind, value, toolName));
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Voice/FillerOptions.cs b/src/AgentCore.AspNetCore/Voice/FillerOptions.cs
new file mode 100644
index 00000000..bba25e65
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/FillerOptions.cs
@@ -0,0 +1,17 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:112-131
+// (RunContext.with_filler's parameters), commit d8405f132e1bd960f298190c18daf81ffc1faf45.
+// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to
+// C#; a tool cannot open this itself (G5), so it is voice-options input to ToolFillerScope instead of a
+// RunContext call. Per tool id, off by default — plan Q1/owner ruling.
+
+namespace AgentCore.AspNetCore.Voice
+{
+ /// One tool id's filler: what to say while its step is still running, and how often.
+ /// What to say. See .
+ /// How long the session must stay continuously idle before the first fire.
+ ///
+ /// The wall-clock cooldown after a fire before the dwell restarts. fires at most once.
+ ///
+ /// The most fires across the tool call's lifetime. means no limit.
+ internal sealed record FillerOptions(FillerSource Source, TimeSpan Delay, TimeSpan? Interval = null, int? MaxSteps = null);
+}
diff --git a/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs b/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs
new file mode 100644
index 00000000..c5900f8b
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs
@@ -0,0 +1,201 @@
+// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/filler_scheduler.py:16-123
+// (_FillerScheduler), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc.
+// Licensed under the Apache License, Version 2.0. Modified: translated to C#; reset_dwell is not ported
+// (its only caller, ctx.update() for async tools, is out of scope per plan section 3).
+
+namespace AgentCore.AspNetCore.Voice
+{
+ ///
+ /// Fires filler speech after the session has been continuously idle for a dwell, while the
+ /// speech this instance was created for is still running.
+ ///
+ internal sealed class FillerScheduler : IAsyncDisposable
+ {
+ private readonly VoiceSession _session;
+
+ private readonly SpeechHandle _speechHandle;
+
+ private readonly FillerSource _source;
+
+ private readonly TimeSpan _delay;
+
+ private readonly TimeSpan? _interval;
+
+ private readonly int? _maxSteps;
+
+ private readonly CancellationTokenSource _cancellation = new();
+
+ private readonly AsyncEvent _speaking = new();
+
+ private readonly List _created = [];
+
+ private readonly Task _mainTask;
+
+ private readonly Lock _gate = new();
+
+ private bool _closing;
+
+ /// Where idleness, state changes, and say come from.
+ /// The loop stops early once this speech is interrupted.
+ /// What to say when the dwell elapses.
+ /// How long the session must stay continuously idle before the first fire.
+ /// The wall-clock cooldown after a fire, or to fire at most once.
+ /// The most fires across this instance's lifetime, or for no limit.
+ public FillerScheduler(
+ VoiceSession session,
+ SpeechHandle speechHandle,
+ FillerSource source,
+ TimeSpan delay,
+ TimeSpan? interval = null,
+ int? maxSteps = null)
+ {
+ if (delay < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(delay), delay, "delay must be non-negative");
+ }
+
+ if (interval is { } value && value < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(interval), interval, "interval must be non-negative when set");
+ }
+
+ _session = session;
+ _speechHandle = speechHandle;
+ _source = source;
+ _delay = delay;
+ _interval = interval;
+ _maxSteps = maxSteps;
+ _mainTask = Task.Run(RunAsync);
+ }
+
+ /// Gets every speech this instance made create, or resolved
+ /// straight from a callable source, in fire order.
+ public IReadOnlyList CreatedSpeeches => _created;
+
+ /// Stops the loop, if it has not already stopped on its own.
+ /// No fire starts once this is called, even one whose dwell has already elapsed.
+ public async Task CloseAsync()
+ {
+ lock (_gate)
+ {
+ _closing = true;
+ }
+
+ if (!_mainTask.IsCompleted)
+ {
+ await TaskTeardown.CancelAndWaitAsync(_cancellation, [_mainTask]).ConfigureAwait(false);
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ return new ValueTask(CloseAsync());
+ }
+
+ private async Task RunAsync()
+ {
+ void OnAgentState(AgentStateChanged change)
+ {
+ if (change.NewState is AgentState.Speaking or AgentState.Thinking)
+ {
+ _speaking.Set();
+ }
+ }
+
+ void OnUserState(UserStateChanged change)
+ {
+ if (change.NewState == UserState.Speaking)
+ {
+ _speaking.Set();
+ }
+ }
+
+ _session.AgentStateChanged += OnAgentState;
+ _session.UserStateChanged += OnUserState;
+
+ Task loop = Task.Run(() => LoopAsync(_cancellation.Token), _cancellation.Token);
+ try
+ {
+ await _speechHandle.WaitIfNotInterruptedAsync([loop]).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (!loop.IsCompleted)
+ {
+ await TaskTeardown.CancelAndWaitAsync(_cancellation, [loop]).ConfigureAwait(false);
+ }
+ else if (loop.Exception is { } fault)
+ {
+ // WaitIfNotInterruptedAsync collects the loop's fault instead of raising it, so a
+ // filler that dies on its first fire would otherwise leave no trace.
+ VoiceConversationLog.FillerFaulted(_session.Logger, fault.GetBaseException());
+ }
+
+ _session.AgentStateChanged -= OnAgentState;
+ _session.UserStateChanged -= OnUserState;
+ }
+ }
+
+ private async Task LoopAsync(CancellationToken cancellationToken)
+ {
+ while (true)
+ {
+ await _session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
+ _speaking.Clear();
+
+ try
+ {
+ await _speaking.WaitAsync(cancellationToken).WaitAsync(_delay, _session.Time, cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+ catch (TimeoutException)
+ {
+ // The dwell elapsed with no interruption: fire below.
+ }
+
+ if (!TryFire())
+ {
+ return;
+ }
+
+ if (_interval is not { } interval || (_maxSteps is { } max && _created.Count >= max))
+ {
+ return;
+ }
+
+ await Task.Delay(interval, _session.Time, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///